magus-cli 7.9.0 → 7.10.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 (29) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/plugin-deps/doctor.test.ts +1 -0
  3. package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
  4. package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
  5. package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
  6. package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
  7. package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
  8. package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
  9. package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
  10. package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
  11. package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
  12. package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
  13. package/src/__tests__/deps-plan.test.ts +3 -2
  14. package/src/__tests__/plugin-mcp-source-shapes.test.ts +250 -0
  15. package/src/__tests__/worktree-profiles.test.ts +583 -0
  16. package/src/cli/doctor.ts +24 -2
  17. package/src/cli/update-view.ts +73 -0
  18. package/src/cli/update.ts +21 -0
  19. package/src/cli/worktrees.ts +32 -0
  20. package/src/services/deps-plan.ts +273 -81
  21. package/src/services/git-worktree.ts +41 -0
  22. package/src/services/plugin-mcp-config.ts +3 -1
  23. package/src/services/worktree-profiles.ts +181 -0
  24. package/src/shared/plugin/hooks/bun-missing.sh +35 -10
  25. package/src/shared/plugin/hooks/deps-check.ts +25 -9
  26. package/src/shared/plugin/hooks/lib/requires-probe.ts +101 -5
  27. package/src/ui/App.tsx +32 -1
  28. package/src/ui/adapters/startupReports.ts +22 -2
  29. package/src/utils/run.ts +21 -1
@@ -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
+ }
@@ -3,11 +3,13 @@
3
3
  # runs this only when Bun is not on the PATH Claude Code started with, which is
4
4
  # the one dependency the TypeScript check cannot report itself. $1 is the label.
5
5
  #
6
- # Writes the same ledger as deps-check.ts: seen/<label> = sh, then claims the
7
- # Bun requirement's dedupe key, so a session prints one Bun line however many
8
- # plugins carry this file. scripts/check-hook-copies.ts recomputes that key, the
9
- # install line and the PATH lines from lib/requires-probe.ts and fails when this
10
- # file disagrees. Exit 0 always.
6
+ # Writes the same ledger as deps-check.ts, 0700: seen/<label> = sh, then claims
7
+ # the Bun requirement's dedupe key, so a session prints one Bun line however
8
+ # many plugins carry this file. A bun in a place an installer or Homebrew puts
9
+ # one is reported as off the PATH, naming that place; only when there is none
10
+ # is Bun called missing. scripts/check-hook-copies.ts recomputes the key, the
11
+ # install line, the Bun prefix and the PATH line formats from
12
+ # lib/requires-probe.ts and fails when this file disagrees. Exit 0 always.
11
13
 
12
14
  label=$1
13
15
  case $label in
@@ -17,6 +19,7 @@ key=bun-3182ab42b0f1
17
19
 
18
20
  sid=$(cat 2>/dev/null | tr -d '\n' | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p')
19
21
  if [ -n "$sid" ]; then
22
+ umask 077
20
23
  base=${TMPDIR:-/tmp}
21
24
  dir=$base/magus-deps-$sid
22
25
  if mkdir -p "$dir/seen" "$dir/claimed" 2>/dev/null; then
@@ -26,13 +29,35 @@ if [ -n "$sid" ]; then
26
29
  fi
27
30
  fi
28
31
 
29
- if [ -x "$HOME/.bun/bin/bun" ]; then
32
+ # The first bun where an installer or Homebrew leaves one. A path that would
33
+ # break the JSON line is skipped.
34
+ found=
35
+ for d in ${BUN_INSTALL:+"$BUN_INSTALL/bin"} "$HOME/.bun/bin" /opt/homebrew/bin /usr/local/bin /home/linuxbrew/.linuxbrew/bin; do
36
+ case $d in
37
+ *\"* | *\\* | *[[:cntrl:]]*) continue ;;
38
+ esac
39
+ if [ -f "$d/bun" ] && [ -x "$d/bun" ]; then
40
+ found=$d
41
+ break
42
+ fi
43
+ done
44
+
45
+ if [ -n "$found" ]; then
46
+ # Under $HOME: shown as ~/…, and $HOME in the PATH line.
47
+ rel=${found#"$HOME"/}
48
+ if [ -n "$HOME" ] && [ "$HOME" != / ] && [ "$rel" != "$found" ]; then
49
+ shown="~/$rel" pathdir="\$HOME/$rel"
50
+ else
51
+ shown=$found pathdir=$found
52
+ fi
30
53
  case ${SHELL:-} in
31
- *zsh) rc='~/.zshrc' line='export PATH=\"$HOME/.bun/bin:$PATH\"' ;;
32
- *fish) rc='~/.config/fish/config.fish' line='fish_add_path $HOME/.bun/bin' ;;
33
- *) rc='~/.bashrc' line='export PATH=\"$HOME/.bun/bin:$PATH\"' ;;
54
+ *zsh) rc='~/.zshrc' fmt='export PATH="%s:$PATH"' ;;
55
+ *fish) rc='~/.config/fish/config.fish' fmt='fish_add_path %s' ;;
56
+ *) rc='~/.bashrc' fmt='export PATH="%s:$PATH"' ;;
34
57
  esac
35
- printf '{"systemMessage":"magus-deps: Bun is installed at ~/.bun/bin but is not on the PATH Claude Code started with, so magus hooks and MCP servers cannot run (noticed by %s).\\n Add %s to %s, then restart Claude Code from a new terminal."}\n' "$label" "$line" "$rc"
58
+ # shellcheck disable=SC2059 # fmt is one of the three fixed formats above
59
+ line=$(printf "$fmt" "$pathdir" | sed 's/["\\]/\\&/g')
60
+ printf '{"systemMessage":"magus-deps: Bun is installed at %s but is not on the PATH Claude Code started with, so magus hooks and MCP servers cannot run (noticed by %s).\\n Add %s to %s, then restart Claude Code from a new terminal."}\n' "$shown" "$label" "$line" "$rc"
36
61
  else
37
62
  printf '{"systemMessage":"magus-deps: Bun is missing — magus hooks and MCP servers need it (noticed by %s).\\n Install: curl -fsSL https://bun.sh/install | bash, then restart Claude Code from a new terminal."}\n' "$label"
38
63
  fi
@@ -17,7 +17,9 @@
17
17
  * atomic `mkdir claimed/<dedupeKey>`. A failed mkdir means another plugin's copy
18
18
  * reports that dep this session, so one dep is one banner line however many
19
19
  * plugins need it. An unreadable session id means no ledger and no claims:
20
- * more banners, never fewer. Ledgers older than a day are pruned on write.
20
+ * more banners, never fewer. The ledger is 0700. Ledgers older than a day are
21
+ * pruned on write, each on its own, so one this user cannot remove skips only
22
+ * itself.
21
23
  *
22
24
  * Exit 0 always. A watchdog keeps the hook inside its registration timeout.
23
25
  */
@@ -94,28 +96,42 @@ async function readHookInput(): Promise<HookInput> {
94
96
  }
95
97
  }
96
98
 
99
+ /**
100
+ * Remove ledgers older than a day. Each is tried on its own: a shared `/tmp`
101
+ * holds other users' ledgers, which this user cannot remove, and one of those
102
+ * must not stop this user's own from being pruned.
103
+ */
97
104
  function pruneOldLedgers(base: string): void {
105
+ let entries: string[];
98
106
  try {
99
- for (const entry of readdirSync(base)) {
100
- if (!entry.startsWith("magus-deps-")) continue;
101
- const dir = join(base, entry);
107
+ entries = readdirSync(base);
108
+ } catch {
109
+ return; // Pruning is housekeeping; a failure costs nothing now.
110
+ }
111
+ for (const entry of entries) {
112
+ if (!entry.startsWith("magus-deps-")) continue;
113
+ const dir = join(base, entry);
114
+ try {
102
115
  if (Date.now() - statSync(dir).mtimeMs > LEDGER_MAX_AGE_MS) {
103
116
  rmSync(dir, { recursive: true, force: true });
104
117
  }
118
+ } catch {
119
+ // Another user's, or already gone: the next entry is still pruned.
105
120
  }
106
- } catch {
107
- // Pruning is housekeeping; a failure costs nothing now.
108
121
  }
109
122
  }
110
123
 
111
- /** The ledger directory, with `seen/<label>` written, or null without a session. */
124
+ /**
125
+ * The ledger directory, with `seen/<label>` written, or null without a session.
126
+ * Created 0700: in a shared `/tmp`, no other user reads which plugins ran.
127
+ */
112
128
  function openLedger(hookSession: string | null, label: string): string | null {
113
129
  if (!hookSession) return null;
114
130
  const base = process.env.TMPDIR || tmpdir();
115
131
  const dir = join(base, `magus-deps-${hookSession}`);
116
132
  try {
117
- mkdirSync(join(dir, "seen"), { recursive: true });
118
- mkdirSync(join(dir, "claimed"), { recursive: true });
133
+ mkdirSync(join(dir, "seen"), { recursive: true, mode: 0o700 });
134
+ mkdirSync(join(dir, "claimed"), { recursive: true, mode: 0o700 });
119
135
  writeFileSync(join(dir, "seen", label), "ts");
120
136
  } catch {
121
137
  return null;
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { spawn } from "node:child_process";
23
23
  import { createHash } from "node:crypto";
24
- import { constants, accessSync, statSync } from "node:fs";
24
+ import { constants, accessSync, readFileSync, statSync } from "node:fs";
25
25
  import { delimiter, join } from "node:path";
26
26
 
27
27
  /** The one `requires` shape this reader knows. Any change to it bumps this. */
@@ -51,6 +51,17 @@ export interface Bootstrap {
51
51
  prefix: UserPrefix;
52
52
  /** Tools the upstream installer script itself runs. */
53
53
  needs: readonly string[];
54
+ /**
55
+ * The environment the installer runs with: what keeps it out of the user's
56
+ * rc files, so the PATH line magus prints is the only one.
57
+ */
58
+ env?: Readonly<Record<string, string>>;
59
+ /**
60
+ * The installer adds `prefix` to the user's rc file itself and has no
61
+ * switch to stop it, so magus prints no PATH line ahead of it; once it has
62
+ * run, the rc file is read to see whether it did.
63
+ */
64
+ editsRc?: true;
54
65
  }
55
66
 
56
67
  /**
@@ -64,12 +75,14 @@ export const TOOLCHAIN_BOOTSTRAP = {
64
75
  interpreter: "bash",
65
76
  prefix: BUN_PREFIX,
66
77
  needs: ["curl", "unzip"],
78
+ editsRc: true,
67
79
  },
68
80
  uv: {
69
81
  url: "https://astral.sh/uv/install.sh",
70
82
  interpreter: "sh",
71
83
  prefix: LOCAL_PREFIX,
72
84
  needs: ["curl"],
85
+ env: { UV_NO_MODIFY_PATH: "1" },
73
86
  },
74
87
  } as const satisfies Record<string, Bootstrap>;
75
88
 
@@ -679,6 +692,79 @@ export function rcFile(shell: string): string {
679
692
  return "~/.bashrc";
680
693
  }
681
694
 
695
+ /** The files `shell` reads when a terminal starts it, `rcFile(shell)` first. */
696
+ function startupFiles(shell: string): string[] {
697
+ if (shell.endsWith("zsh")) return ["~/.zshrc", "~/.zprofile", "~/.zshenv"];
698
+ if (shell.endsWith("fish")) return ["~/.config/fish/config.fish"];
699
+ return ["~/.bashrc", "~/.bash_profile"];
700
+ }
701
+
702
+ const SHELL_VAR_RE = /\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g;
703
+ const ASSIGN_RE = /^(?:export\s+)?([A-Za-z_]\w*)=(.*)$/;
704
+ const FISH_SET_RE = /^set\s+(?:-\S+\s+)*([A-Za-z_]\w*)\s+(.*)$/;
705
+ const PATH_EDIT_RE = /\bPATH\b|^fish_add_path\b|^path\+?=/;
706
+
707
+ /**
708
+ * Does `text` (one rc file) put `abs` on PATH? A line that edits PATH counts
709
+ * when, with `~`, `$HOME` and the variables the file assigns earlier
710
+ * substituted, one of its words is `abs`. That reads the line magus prints,
711
+ * a hand-written one, and bun's installer's
712
+ * `export BUN_INSTALL="$HOME/.bun"` + `export PATH="$BUN_INSTALL/bin:$PATH"`.
713
+ */
714
+ function textAddsToPath(text: string, abs: string, home: string): boolean {
715
+ const vars = new Map([["HOME", home]]);
716
+ const expand = (s: string): string =>
717
+ s
718
+ .replace(
719
+ SHELL_VAR_RE,
720
+ (whole: string, braced?: string, bare?: string) =>
721
+ vars.get(braced ?? bare ?? "") ?? whole,
722
+ )
723
+ .replace(
724
+ /(^|[\s:="'(])~(?=\/|$)/g,
725
+ (_whole: string, lead: string) => `${lead}${home}`,
726
+ );
727
+ for (const raw of text.split("\n")) {
728
+ const line = raw.trim();
729
+ if (line === "" || line.startsWith("#")) continue;
730
+ if (PATH_EDIT_RE.test(line)) {
731
+ const words = expand(line).split(/[\s:"'()=]+/);
732
+ if (words.some((w) => w === abs || w === `${abs}/`)) return true;
733
+ }
734
+ const assign = line.match(ASSIGN_RE) ?? line.match(FISH_SET_RE);
735
+ if (assign?.[1] && assign[2] !== undefined) {
736
+ const value = expand(assign[2]);
737
+ const quoted = /^(["']).*\1$/.test(value);
738
+ vars.set(assign[1], quoted ? value.slice(1, -1) : value);
739
+ }
740
+ }
741
+ return false;
742
+ }
743
+
744
+ /**
745
+ * The startup file of `shell` (as `~/…`) that already puts `dir` on PATH, or
746
+ * null. A PATH line is advice only while no such file exists: when one does,
747
+ * the prefix is off the PATH only because Claude Code started without reading
748
+ * it, and the fix is a restart, not a second line.
749
+ */
750
+ export function rcFileAdding(
751
+ dir: string,
752
+ shell: string,
753
+ home: string,
754
+ ): string | null {
755
+ const abs = expandHome(dir, home).replace(/\/+$/, "");
756
+ for (const file of startupFiles(shell)) {
757
+ let text: string;
758
+ try {
759
+ text = readFileSync(expandHome(file, home), "utf8");
760
+ } catch {
761
+ continue;
762
+ }
763
+ if (textAddsToPath(text, abs, home)) return file;
764
+ }
765
+ return null;
766
+ }
767
+
682
768
  // ─── presence ─────────────────────────────────────────────────────────────────
683
769
 
684
770
  export type Presence =
@@ -824,6 +910,8 @@ export type InstallStep =
824
910
  url: string;
825
911
  interpreter: "sh" | "bash";
826
912
  prefix: UserPrefix;
913
+ /** The installer's environment, from {@link Bootstrap.env}. */
914
+ env?: Record<string, string>;
827
915
  }
828
916
  | {
829
917
  kind: "release";
@@ -980,13 +1068,14 @@ export function renderInstall(
980
1068
  };
981
1069
  }
982
1070
  case "bootstrap": {
983
- const b = TOOLCHAIN_BOOTSTRAP[option.tool];
1071
+ const b: Bootstrap = TOOLCHAIN_BOOTSTRAP[option.tool];
984
1072
  return {
985
1073
  kind: "bootstrap",
986
1074
  name,
987
1075
  url: b.url,
988
1076
  interpreter: b.interpreter,
989
1077
  prefix: b.prefix,
1078
+ ...(b.env ? { env: { ...b.env } } : {}),
990
1079
  };
991
1080
  }
992
1081
  case "uv-script":
@@ -1054,8 +1143,12 @@ export function describeStep(step: InstallStep, os: Os): string {
1054
1143
  const sudo = step.escalation === "sudo" ? "sudo " : "";
1055
1144
  return `${sync} && ${sudo}"$(${find})" ${run}`;
1056
1145
  }
1057
- case "bootstrap":
1058
- return `curl -fsSL ${step.url} | ${step.interpreter}`;
1146
+ case "bootstrap": {
1147
+ const env = Object.entries(step.env ?? {}).map(([k, v]) => `${k}=${v}`);
1148
+ const run =
1149
+ env.length > 0 ? ["env", ...env, step.interpreter] : [step.interpreter];
1150
+ return `curl -fsSL ${step.url} | ${shellLine(run)}`;
1151
+ }
1059
1152
  case "release": {
1060
1153
  const asset = step.url.slice(step.url.lastIndexOf("/") + 1);
1061
1154
  const verify = os === "darwin" ? "shasum -a 256 -c" : "sha256sum -c";
@@ -1174,8 +1267,11 @@ export function renderFix(
1174
1267
  for (const p of offPath) {
1175
1268
  const prefix = USER_PREFIXES.find((u) => u.dir === p.prefix);
1176
1269
  const rc = prefix?.rcLine(shell) ?? "";
1270
+ const adding = rcFileAdding(p.prefix, shell, env.home);
1177
1271
  lines.push(
1178
- ` ${p.name} is in ${p.prefix}, which is not on the PATH Claude Code started with: add ${rc} to ${rcFile(shell)}`,
1272
+ adding
1273
+ ? ` ${p.name} is in ${p.prefix}, which is not on the PATH Claude Code started with; ${adding} already puts it on PATH`
1274
+ : ` ${p.name} is in ${p.prefix}, which is not on the PATH Claude Code started with: add ${rc} to ${rcFile(shell)}`,
1179
1275
  );
1180
1276
  }
1181
1277
  lines.push(" Then restart Claude Code from a new terminal.");
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");
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The failure popup's words for everything outside the Plugins screen's apply
3
3
  * paths: the profile gate at startup, a profile activation, the startup
4
- * repairs (stale plugin loads, marketplace refreshes, gitignore fixes) and the
5
- * Git State screen. Each is a pure builder over what the service returned, on
4
+ * repairs (stale plugin loads, marketplace refreshes, gitignore fixes), the
5
+ * setup of other worktrees and the Git State screen. Each is a pure builder over what the service returned, on
6
6
  * top of {@link failureReport}, so they read like every other failure.
7
7
  *
8
8
  * Each returns null when nothing failed: success says nothing, because the
@@ -13,6 +13,7 @@ import type { StaleLoadRepairResult } from "../../services/claude-settings.js";
13
13
  import type { ActivationResult } from "../../services/profile-activate.js";
14
14
  import { SettingsParseError } from "../../services/settings-file.js";
15
15
  import { pluginName } from "../../services/update-engine.js";
16
+ import type { WorktreeSetupResult } from "../../services/worktree-profiles.js";
16
17
  import { userFacingPath, withUserFacingPath } from "../../utils/user-path.js";
17
18
  import type { FailureReport, FailureRow } from "../state/types.js";
18
19
  import {
@@ -212,6 +213,25 @@ export function stepFailureReport(
212
213
  };
213
214
  }
214
215
 
216
+ /**
217
+ * Worktrees the startup setup could not put on their declared profile, each
218
+ * by folder name with its reason. Null when every one was set up.
219
+ */
220
+ export function worktreeSetupReport(
221
+ results: readonly WorktreeSetupResult[],
222
+ ): FailureReport | null {
223
+ const failed = results.filter(
224
+ (r): r is Extract<WorktreeSetupResult, { ok: false }> => !r.ok,
225
+ );
226
+ return failureReport(
227
+ `${counted(failed.length, "worktree")} ${failed.length === 1 ? "was" : "were"} not set up`,
228
+ failed.map((r) => ({
229
+ subject: r.name,
230
+ reason: exceptionReason(r.reason).reason,
231
+ })),
232
+ );
233
+ }
234
+
215
235
  /**
216
236
  * M9: generated files the startup gitignore fix could not untrack, each with
217
237
  * git's own reason.
package/src/utils/run.ts CHANGED
@@ -41,6 +41,11 @@ export interface RunResult {
41
41
  spawnError?: string;
42
42
  /** Combined stdout + stderr, last {@link OUTPUT_TAIL_BYTES}, ANSI stripped. */
43
43
  outputTail: string;
44
+ /**
45
+ * stdout alone, last {@link OUTPUT_TAIL_BYTES}, ANSI stripped. Set only
46
+ * when `captureStdout` asked for it.
47
+ */
48
+ stdout?: string;
44
49
  durationMs: number;
45
50
  }
46
51
 
@@ -53,6 +58,12 @@ export interface RunOptions {
53
58
  killGraceMs?: number;
54
59
  /** Each output chunk as it arrives, ANSI intact. Never called after return. */
55
60
  onOutput?: (chunk: string) => void;
61
+ /**
62
+ * Also keep stdout on its own in `RunResult.stdout`: for a query whose
63
+ * answer is what it prints on stdout, so a warning on stderr is never read
64
+ * as the answer.
65
+ */
66
+ captureStdout?: boolean;
56
67
  }
57
68
 
58
69
  /** Keep the last {@link OUTPUT_TAIL_BYTES} of a growing buffer. */
@@ -67,10 +78,13 @@ export function runArgv(c: Command, opts: RunOptions): Promise<RunResult> {
67
78
  const started = Date.now();
68
79
  return new Promise((resolve) => {
69
80
  let output = "";
81
+ let stdout = "";
70
82
  let timedOut = false;
71
83
  let settled = false;
72
84
  let killTimer: ReturnType<typeof setTimeout> | undefined;
73
- const finish = (result: Omit<RunResult, "outputTail" | "durationMs">) => {
85
+ const finish = (
86
+ result: Omit<RunResult, "outputTail" | "stdout" | "durationMs">,
87
+ ) => {
74
88
  if (settled) return;
75
89
  settled = true;
76
90
  clearTimeout(timer);
@@ -78,6 +92,7 @@ export function runArgv(c: Command, opts: RunOptions): Promise<RunResult> {
78
92
  resolve({
79
93
  ...result,
80
94
  outputTail: stripAnsi(tail(output)),
95
+ ...(opts.captureStdout ? { stdout: stripAnsi(tail(stdout)) } : {}),
81
96
  durationMs: Date.now() - started,
82
97
  });
83
98
  };
@@ -93,6 +108,11 @@ export function runArgv(c: Command, opts: RunOptions): Promise<RunResult> {
93
108
  };
94
109
  child.stdout?.on("data", onData);
95
110
  child.stderr?.on("data", onData);
111
+ if (opts.captureStdout) {
112
+ child.stdout?.on("data", (data: Buffer) => {
113
+ stdout = tail(stdout + data.toString("utf8"));
114
+ });
115
+ }
96
116
  const timer = setTimeout(() => {
97
117
  timedOut = true;
98
118
  child.kill("SIGTERM");