magus-cli 7.9.1 → 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.
@@ -34,6 +34,10 @@ import {
34
34
  type UpdatePlan,
35
35
  isKeptDeprecated,
36
36
  } from "../services/update-plan.js";
37
+ import type {
38
+ UnsetWorktree,
39
+ WorktreeSetupResult,
40
+ } from "../services/worktree-profiles.js";
37
41
  import { type ReportBadgeKind, reportBadge } from "../ui/theme-mode.js";
38
42
  import { brand } from "../ui/theme.js";
39
43
  import { formatCommand } from "../utils/command-utils.js";
@@ -86,6 +90,7 @@ type ActionKey =
86
90
  | "refresh"
87
91
  | "stale-load"
88
92
  | "remove"
93
+ | "external"
89
94
  | "unknown";
90
95
 
91
96
  const ACTIONS: Record<ActionKey, ActionStyle> = {
@@ -107,6 +112,9 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
107
112
  // dead weight Claude Code refuses to load, and the row is work, not a shrug.
108
113
  // The plugin table shows the group's DEPRECATED chip, not this label.
109
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 },
110
118
  // Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
111
119
  // as "broken". The summary line and the TUI already use warning for it.
112
120
  unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
@@ -449,6 +457,7 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
449
457
  // plain one reads identically to before.
450
458
  item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
451
459
  }`;
460
+ case "external":
452
461
  case "unknown":
453
462
  return `${item.installed ?? "—"}${
454
463
  item.note ? ` ${dim(`(${item.note})`)}` : ""
@@ -903,3 +912,66 @@ export function pendingHeadline(plan: UpdatePlan): string {
903
912
  !plan.skills.some((s) => s.action === "install");
904
913
  return onlyRemovals ? "Changes pending" : "Updates available";
905
914
  }
915
+
916
+ // -- other worktrees -----------------------------------------------------------
917
+ //
918
+ // The other working trees of this repository that have no profile set up
919
+ // (`findUnsetWorktrees`). `magus update` sets up the ones the project already
920
+ // answered for, exactly as it does its own folder, and says so in one line.
921
+ // No section and no question: there is nothing to decide. Read-only runs
922
+ // (`magus doctor`, `--check`, `--dry-run`) get the same one line as a warning.
923
+
924
+ /** `Worktree a` or `Worktrees a, b, c + 2 more`, so the row stays one line. */
925
+ function worktreeLabel(names: readonly string[]): string {
926
+ const shown = names.slice(0, 3).join(", ");
927
+ const list = names.length > 3 ? `${shown} + ${names.length - 3} more` : shown;
928
+ return `${names.length === 1 ? "Worktree" : "Worktrees"} ${bold(list)}`;
929
+ }
930
+
931
+ /**
932
+ * The worktree rows: after a setup (`results`), one `✓` line for every
933
+ * worktree set up and a `✗` line for each failure; on a read-only run
934
+ * (`results` null), one `⚠` line for the ones `magus update` would set up.
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.
941
+ */
942
+ export function worktreeLines(
943
+ worktrees: readonly UnsetWorktree[],
944
+ results: readonly WorktreeSetupResult[] | null,
945
+ ): string[] {
946
+ const lines: string[] = [];
947
+ if (results === null) {
948
+ const auto = worktrees.filter((w) => w.autoActivate).map((w) => w.name);
949
+ if (auto.length > 0) {
950
+ const one = auto.length === 1;
951
+ lines.push(
952
+ warn(
953
+ `${worktreeLabel(auto)} ${one ? "is" : "are"} missing this project's plugins. magus update sets ${one ? "it" : "them"} up.`,
954
+ ),
955
+ );
956
+ }
957
+ } else {
958
+ const done = results.filter((r) => r.ok).map((r) => r.name);
959
+ if (done.length > 0) {
960
+ lines.push(
961
+ ok(
962
+ `${worktreeLabel(done)} now ${done.length === 1 ? "has" : "have"} this project's plugins.`,
963
+ ),
964
+ );
965
+ }
966
+ for (const r of results) {
967
+ if (!r.ok) {
968
+ lines.push(
969
+ fail(
970
+ `Worktree ${bold(r.name)} was not set up: ${r.reason.replace(/\.+$/, "")}.`,
971
+ ),
972
+ );
973
+ }
974
+ }
975
+ }
976
+ return lines.length > 0 ? ["", ...lines] : [];
977
+ }
package/src/cli/update.ts CHANGED
@@ -84,6 +84,7 @@ import {
84
84
  planSkillUpdates,
85
85
  summarizePlan,
86
86
  } from "../services/update-plan.js";
87
+ import type { ProfileManifest } from "../types/index.js";
87
88
  import { brand } from "../ui/theme.js";
88
89
  import { bold, dim, fg, width } from "./ansi.js";
89
90
  import { ensureManifest, requireActiveProfile } from "./bootstrap.js";
@@ -108,6 +109,7 @@ import {
108
109
  unknownToolsNotice,
109
110
  warn as warnLine,
110
111
  } from "./update-view.js";
112
+ import { setUpOtherWorktrees } from "./worktrees.js";
111
113
 
112
114
  interface UpdateFlags {
113
115
  /** Report drift and exit 1 if anything is behind. The CI gate. */
@@ -250,6 +252,25 @@ export async function runUpdateCommand(
250
252
  // what it promised. A refused `--check` exits 1, because that is a gate.
251
253
  if (!current) return flags.dryRun ? 0 : 1;
252
254
 
255
+ const code = await updateProfile(flags, projectPath, current, startedAt);
256
+
257
+ // This project is on a profile; now the other worktrees of the repository,
258
+ // set up the same way without asking. After the update, so their line is
259
+ // not lost above the plan. Read-only under `--check` and `--dry-run`, and
260
+ // never a change to the exit code: those are other projects.
261
+ await setUpOtherWorktrees(projectPath, {
262
+ mayWrite: !flags.check && !flags.dryRun,
263
+ });
264
+ return code;
265
+ }
266
+
267
+ /** The update itself, once the project's own profile gate has passed. */
268
+ async function updateProfile(
269
+ flags: UpdateFlags,
270
+ projectPath: string,
271
+ current: ProfileManifest,
272
+ startedAt: number,
273
+ ): Promise<number> {
253
274
  const profileIds = Object.keys(current.profiles);
254
275
  if (flags.profile && !current.profiles[flags.profile]) {
255
276
  console.error(
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The other worktrees of this repository at the end of `magus update`: the ones
3
+ * with no profile set up. Each one whose project already declares a profile,
4
+ * with nothing of the user's to replace, is set up without asking, exactly as
5
+ * `magus update` sets up its own folder. Then one line says so.
6
+ *
7
+ * Detection and apply are `services/worktree-profiles.ts`, shared with
8
+ * `magus doctor` and the TUI; the words are `update-view.ts`.
9
+ */
10
+
11
+ import {
12
+ findUnsetWorktrees,
13
+ setUpWorktrees,
14
+ } from "../services/worktree-profiles.js";
15
+ import { worktreeLines } from "./update-view.js";
16
+
17
+ /**
18
+ * Set up what can be set up when `mayWrite`, and print the rows. Under
19
+ * `--check` and `--dry-run` it only reports. Never changes the command's exit
20
+ * code: these are other projects, and a failure in one is reported on its row.
21
+ */
22
+ export async function setUpOtherWorktrees(
23
+ projectPath: string,
24
+ opts: { mayWrite: boolean },
25
+ ): Promise<void> {
26
+ const worktrees = await findUnsetWorktrees(projectPath);
27
+ if (worktrees.length === 0) return;
28
+ const results = opts.mayWrite
29
+ ? await setUpWorktrees(worktrees.filter((w) => w.autoActivate))
30
+ : null;
31
+ for (const line of worktreeLines(worktrees, results)) console.log(line);
32
+ }
@@ -190,6 +190,47 @@ export async function inheritablePaths(projectPath: string): Promise<string[]> {
190
190
  return paths;
191
191
  }
192
192
 
193
+ /** One working tree of a repository, as `git worktree list` reports it. */
194
+ export interface WorkingTree {
195
+ /** Absolute path, as git recorded it. */
196
+ path: string;
197
+ /** git says its folder is gone (`prunable`); the record outlives it. */
198
+ prunable: boolean;
199
+ }
200
+
201
+ /**
202
+ * Every working tree of `projectPath`'s repository — the main one first, then
203
+ * each linked worktree — and the one `projectPath` itself sits in.
204
+ *
205
+ * Null when git cannot say: not a repository, git missing or not answering.
206
+ * A bare repository's own record has no working tree and is left out. Reads
207
+ * only; never caches, because a worktree added while magus runs is exactly
208
+ * what a caller of this wants to see.
209
+ */
210
+ export async function listWorkingTrees(
211
+ projectPath: string,
212
+ ): Promise<{ self: string; trees: WorkingTree[] } | null> {
213
+ const dir = path.resolve(projectPath);
214
+ const [top, list] = await Promise.all([
215
+ git(dir, ["rev-parse", "--show-toplevel"]),
216
+ git(dir, ["worktree", "list", "--porcelain"]),
217
+ ]);
218
+ if (top.code !== 0 || !top.out || list.code !== 0) return null;
219
+ const trees: WorkingTree[] = [];
220
+ for (const record of list.out.split(/\n\s*\n/)) {
221
+ const lines = record.split("\n").map((l) => l.trim());
222
+ const line = lines.find((l) => l.startsWith("worktree "));
223
+ if (!line || lines.includes("bare")) continue;
224
+ trees.push({
225
+ path: path.resolve(line.slice("worktree ".length)),
226
+ prunable: lines.some(
227
+ (l) => l === "prunable" || l.startsWith("prunable "),
228
+ ),
229
+ });
230
+ }
231
+ return { self: path.resolve(top.out), trees };
232
+ }
233
+
193
234
  async function resolveInheritable(
194
235
  dir: string,
195
236
  ): Promise<{ paths: string[]; answered: boolean }> {
@@ -217,7 +217,9 @@ export async function getPluginSourcePath(
217
217
  const plugin = manifest.plugins.find(
218
218
  (p: { name: string }) => p.name === pluginName,
219
219
  );
220
- return plugin?.source;
220
+ // An object source (git-subdir, github, url) is fetched from its own
221
+ // repo, not read from the marketplace clone, so there is no local path.
222
+ return typeof plugin?.source === "string" ? plugin.source : undefined;
221
223
  }
222
224
  }
223
225
  } catch {
@@ -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"),
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The other working trees of this repository that carry a magus manifest but
3
+ * have no profile in effect — and setting them up.
4
+ *
5
+ * A profiled project's generated files are git-ignored, so git never gives
6
+ * them to a new worktree. Claude Code treats the folder it starts in as the
7
+ * project and reads only that folder's settings, so a session started in such
8
+ * a worktree loads only the plugins turned on at USER scope. magus run in that
9
+ * worktree would fix it (`autoActivate`), but nothing tells the user to.
10
+ *
11
+ * So magus, run anywhere in the repository, looks at the other working trees
12
+ * too. Detection and apply are kept apart, and every surface — `magus update`,
13
+ * `magus doctor` and the TUI — uses these two functions; none of them decides
14
+ * anything of its own beyond how to show the result.
15
+ *
16
+ * Detection writes nothing, anywhere: it is `detectProfileStartupNeed` per
17
+ * working tree, which is detection only. Apply is the activation the normal
18
+ * gate runs for a fresh clone, against THAT worktree's own manifest — nothing
19
+ * is ever copied between worktrees.
20
+ */
21
+
22
+ import { realpath, stat } from "node:fs/promises";
23
+ import path from "node:path";
24
+ import { listWorkingTrees } from "./git-worktree.js";
25
+ import { getManifestPath, readManifest } from "./manifest.js";
26
+ import {
27
+ type ProfileStartupState,
28
+ detectProfileStartupNeed,
29
+ } from "./profile-startup.js";
30
+ import { applyProfileChoice } from "./profile-state.js";
31
+ import { SettingsParseError } from "./settings-file.js";
32
+
33
+ export interface UnsetWorktree {
34
+ /** Absolute path of that working tree. */
35
+ path: string;
36
+ /** Its folder name, how the user knows it. */
37
+ name: string;
38
+ /** From `detectProfileStartupNeed(path)`; never "ok". */
39
+ state: ProfileStartupState;
40
+ declaredDefault: string | null;
41
+ /** The repo declares a profile and nothing would be replaced. */
42
+ autoActivate: boolean;
43
+ }
44
+
45
+ export type WorktreeSetupResult =
46
+ | { path: string; name: string; ok: true; profileId: string }
47
+ | { path: string; name: string; ok: false; reason: string };
48
+
49
+ /** The canonical spelling of a path, so `/tmp` and `/private/tmp` compare equal. */
50
+ async function canonical(p: string): Promise<string> {
51
+ try {
52
+ return await realpath(p);
53
+ } catch {
54
+ return path.resolve(p);
55
+ }
56
+ }
57
+
58
+ async function isDirectory(p: string): Promise<boolean> {
59
+ try {
60
+ return (await stat(p)).isDirectory();
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ async function isFile(p: string): Promise<boolean> {
67
+ try {
68
+ return (await stat(p)).isFile();
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Read-only. The other working trees of `projectPath`'s repository that need
76
+ * setup, sorted by name.
77
+ *
78
+ * Left out: `projectPath`'s own working tree (the normal gate handles it), a
79
+ * working tree whose folder is gone, one with no manifest (not a magus
80
+ * project on that branch), and one already on a profile. Not a repository, or
81
+ * git failing, is `[]`.
82
+ */
83
+ export async function findUnsetWorktrees(
84
+ projectPath: string,
85
+ ): Promise<UnsetWorktree[]> {
86
+ let listing: Awaited<ReturnType<typeof listWorkingTrees>>;
87
+ try {
88
+ listing = await listWorkingTrees(projectPath);
89
+ } catch {
90
+ return [];
91
+ }
92
+ if (listing === null) return [];
93
+ const self = await canonical(listing.self);
94
+
95
+ const found = await Promise.all(
96
+ listing.trees.map(async (tree): Promise<UnsetWorktree | null> => {
97
+ if (tree.prunable || !(await isDirectory(tree.path))) return null;
98
+ const at = await canonical(tree.path);
99
+ if (at === self) return null;
100
+ if (!(await isFile(getManifestPath(at)))) return null;
101
+ try {
102
+ const need = await detectProfileStartupNeed(at);
103
+ if (need.state === "ok") return null;
104
+ return {
105
+ path: at,
106
+ name: path.basename(at),
107
+ state: need.state,
108
+ declaredDefault: need.declaredDefault,
109
+ autoActivate: need.autoActivate && need.declaredDefault !== null,
110
+ };
111
+ } catch {
112
+ // One working tree magus cannot read says nothing about the others.
113
+ return null;
114
+ }
115
+ }),
116
+ );
117
+
118
+ const seen = new Set<string>();
119
+ return found
120
+ .filter((w): w is UnsetWorktree => {
121
+ if (w === null || seen.has(w.path)) return false;
122
+ seen.add(w.path);
123
+ return true;
124
+ })
125
+ .sort((a, b) =>
126
+ a.name === b.name
127
+ ? a.path.localeCompare(b.path)
128
+ : a.name.localeCompare(b.name),
129
+ );
130
+ }
131
+
132
+ /** Why an activation failed, in one line that never ends on an absolute path. */
133
+ function failureReason(error: unknown, worktree: string): string {
134
+ if (error instanceof SettingsParseError) {
135
+ return `${path.relative(worktree, error.path)} ${error.problem}`;
136
+ }
137
+ const message = error instanceof Error ? error.message : String(error);
138
+ return message.split("\n")[0] ?? message;
139
+ }
140
+
141
+ /**
142
+ * Activate the declared profile in each worktree whose `autoActivate` is true,
143
+ * one after another. Entries that need a choice are skipped and not returned;
144
+ * one failure does not stop the others.
145
+ */
146
+ export async function setUpWorktrees(
147
+ worktrees: UnsetWorktree[],
148
+ ): Promise<WorktreeSetupResult[]> {
149
+ const results: WorktreeSetupResult[] = [];
150
+ for (const wt of worktrees) {
151
+ if (!wt.autoActivate || wt.declaredDefault === null) continue;
152
+ try {
153
+ // That worktree's own manifest, read now: its branch may declare
154
+ // something other than this one's.
155
+ const manifest = await readManifest(wt.path);
156
+ const result = await applyProfileChoice(
157
+ { kind: "activate", id: wt.declaredDefault },
158
+ manifest,
159
+ wt.path,
160
+ // Nothing to announce: `autoActivate` means nothing of the user's
161
+ // was there to move aside, and printing here would land inside
162
+ // whichever surface called this.
163
+ { onDisplace: () => {} },
164
+ );
165
+ results.push({
166
+ path: wt.path,
167
+ name: wt.name,
168
+ ok: true,
169
+ profileId: result.profileId,
170
+ });
171
+ } catch (error) {
172
+ results.push({
173
+ path: wt.path,
174
+ name: wt.name,
175
+ ok: false,
176
+ reason: failureReason(error, wt.path),
177
+ });
178
+ }
179
+ }
180
+ return results;
181
+ }
package/src/ui/App.tsx CHANGED
@@ -21,10 +21,15 @@ import {
21
21
  checkForUpdates,
22
22
  getCurrentVersion,
23
23
  } from "../services/version-check.js";
24
+ import {
25
+ findUnsetWorktrees,
26
+ setUpWorktrees,
27
+ } from "../services/worktree-profiles.js";
24
28
  import {
25
29
  gitignoreStartupReport,
26
30
  profileStartupReport,
27
31
  staleRepairReport,
32
+ worktreeSetupReport,
28
33
  } from "./adapters/startupReports.js";
29
34
  import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
30
35
  import { TABS } from "./components/TabBar.js";
@@ -296,8 +301,15 @@ function AppContent({ onExit }: AppContentProps) {
296
301
  null,
297
302
  );
298
303
  const staleLoadModal = useStaleLoadModal();
304
+ // This project's own profile question is settled: it needed nothing, or it
305
+ // was answered. Only then are the other worktrees set up — before it, they
306
+ // would be set up from a project that is not set up itself.
307
+ const [profileSettled, setProfileSettled] = useState(false);
299
308
  // Stable, so `profileModal.show` is not rebuilt for it on every render.
300
- const profileAnswered = React.useCallback(() => setProfileNeed(null), []);
309
+ const profileAnswered = React.useCallback(() => {
310
+ setProfileNeed(null);
311
+ setProfileSettled(true);
312
+ }, []);
301
313
  // Activating a profile changes which plugins are on, so the open screen must
302
314
  // reload. The result modal that used to follow sent the user to the Profiles
303
315
  // tab, which remounted it; without that modal the user stays where they are,
@@ -550,12 +562,31 @@ function AppContent({ onExit }: AppContentProps) {
550
562
  );
551
563
  queueReports(profileStartupReport(problems, activations ?? []));
552
564
  if (!needsNothing(need)) setProfileNeed(need);
565
+ else setProfileSettled(true);
553
566
  } catch {
554
567
  // non-fatal: the Profiles tab still works by hand
555
568
  }
556
569
  })();
557
570
  }, [queueReports]);
558
571
 
572
+ // The other worktrees of this repository with no profile set up, once this
573
+ // project's own question is settled. Each one whose project already declares
574
+ // a profile, with nothing of the user's to replace, is set up without
575
+ // asking — the same rule this project's own profile follows above. Success
576
+ // shows nothing; failures go to the one failure popup. A worktree that needs
577
+ // a choice is left to `magus update` run in it. Its own effect, for the
578
+ // same reason as the profile check above.
579
+ const worktreesLooked = useRef(false);
580
+ useEffect(() => {
581
+ if (!profileSettled || worktreesLooked.current) return;
582
+ worktreesLooked.current = true;
583
+ findUnsetWorktrees(process.cwd())
584
+ .then((found) => setUpWorktrees(found.filter((w) => w.autoActivate)))
585
+ .then((results) => queueReports(worktreeSetupReport(results)))
586
+ // non-fatal: `magus update` reports the same worktrees
587
+ .catch(() => {});
588
+ }, [profileSettled, queueReports]);
589
+
559
590
  // Auto-refresh marketplaces on startup
560
591
  useEffect(() => {
561
592
  const noRefresh = process.argv.includes("--no-refresh");
@@ -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