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
@@ -16,30 +16,32 @@
16
16
  * Re-running is the recovery: a plan is always built from a fresh probe, so a
17
17
  * step whose effect is already present is not planned again; every step is
18
18
  * idempotent or writes its result last (a release renames into place); the apt
19
- * batch starts with `dpkg --configure -a` and waits on the dpkg lock; and only
20
- * one apply runs at a time per user (`DepsLockBusy`).
19
+ * batch starts with `dpkg --configure -a` and waits on the dpkg lock; only one
20
+ * apply runs at a time per user (`DepsLockBusy`); and the temp dirs a killed
21
+ * apply left in a user prefix are swept by the next one.
21
22
  */
22
23
 
23
24
  import { createHash } from "node:crypto";
24
25
  import {
25
26
  chmodSync,
26
- closeSync,
27
+ linkSync,
27
28
  lstatSync,
28
29
  mkdirSync,
29
30
  mkdtempSync,
30
- openSync,
31
31
  readFileSync,
32
+ readdirSync,
32
33
  renameSync,
33
34
  rmSync,
34
35
  unlinkSync,
35
36
  writeFileSync,
36
- writeSync,
37
37
  } from "node:fs";
38
38
  import { tmpdir } from "node:os";
39
39
  import { delimiter, dirname, join } from "node:path";
40
40
  import {
41
41
  type Arch,
42
42
  BUN_PREFIX,
43
+ type Bootstrap,
44
+ type BootstrapTool,
43
45
  type InstallStep,
44
46
  LOCAL_PREFIX,
45
47
  type Os,
@@ -56,6 +58,7 @@ import {
56
58
  pathRcLine,
57
59
  probe,
58
60
  rcFile,
61
+ rcFileAdding,
59
62
  renderInstall,
60
63
  uvScriptArgv,
61
64
  whyUnselectable,
@@ -96,6 +99,7 @@ export interface PlannedStep {
96
99
  installs: InstallTarget[];
97
100
  }
98
101
 
102
+ /** A PATH line the user has to add: never one an rc file already has. */
99
103
  export interface PathAdvice {
100
104
  prefix: UserPrefix;
101
105
  rcFile: string;
@@ -183,16 +187,49 @@ export function prefixOnPath(prefix: UserPrefix, env: PlanEnv): boolean {
183
187
  return env.path.split(delimiter).some((d) => d === dir || d === `${dir}/`);
184
188
  }
185
189
 
186
- /** The PATH line for a prefix, in the user's rc file. */
187
- export function pathAdviceFor(prefix: UserPrefix, env: PlanEnv): PathAdvice {
188
- const info = USER_PREFIXES.find((u) => u.dir === prefix);
190
+ /**
191
+ * The PATH line for a prefix, in the user's rc file; null when a startup file
192
+ * of the user's shell already puts the prefix on PATH. Then there is no line
193
+ * to add: Claude Code started without reading that file, and a restart is the
194
+ * fix, which an off-path step's remedy and the hook banner say.
195
+ */
196
+ export function pathAdviceFor(
197
+ prefix: UserPrefix,
198
+ env: PlanEnv,
199
+ ): PathAdvice | null {
200
+ if (rcFileAdding(prefix, env.userShell, env.home)) return null;
189
201
  return {
190
202
  prefix,
191
203
  rcFile: rcFile(env.userShell),
192
- rcLine: info ? info.rcLine(env.userShell) : "",
204
+ rcLine: pathRcLine(prefix, env.userShell),
193
205
  };
194
206
  }
195
207
 
208
+ /**
209
+ * What to do about `dir` (a user prefix, or another `~/…` or absolute
210
+ * directory) off the PATH Claude Code starts with: add the line, or — when a
211
+ * startup file of the user's shell already puts it on PATH — only restart.
212
+ */
213
+ function pathRemedy(dir: string, env: PlanEnv): string {
214
+ const adding = rcFileAdding(dir, env.userShell, env.home);
215
+ return adding
216
+ ? `${adding} already puts ${dir} on PATH: restart Claude Code from a new terminal`
217
+ : `add ${pathRcLine(dir, env.userShell)} to ${rcFile(env.userShell)}, then restart Claude Code from a new terminal`;
218
+ }
219
+
220
+ /**
221
+ * The prefix a planned step's installer adds to the user's rc file itself
222
+ * ({@link Bootstrap.editsRc}), or null. A bootstrap step is named after its
223
+ * tool: a requirement may bootstrap only the tool it is named after.
224
+ */
225
+ function rcEditedBy(step: InstallStep): UserPrefix | null {
226
+ if (step.kind !== "bootstrap" || !(step.name in TOOLCHAIN_BOOTSTRAP)) {
227
+ return null;
228
+ }
229
+ const b: Bootstrap = TOOLCHAIN_BOOTSTRAP[step.name as BootstrapTool];
230
+ return b.editsRc ? b.prefix : null;
231
+ }
232
+
196
233
  /** A root uv-module step may only come from a plugin of the magus marketplace. */
197
234
  function fromMagusMarketplace(bin: ResolvedBin): boolean {
198
235
  return bin.sources.some(
@@ -214,14 +251,13 @@ export function planDeps(
214
251
  env: PlanEnv,
215
252
  ): DepsPlan {
216
253
  const unfixable: DepsPlan["unfixable"] = [];
217
- const advice = new Map<UserPrefix, PathAdvice>();
254
+ /** The prefixes off the PATH, in the order they came up. */
255
+ const offPathPrefixes = new Set<UserPrefix>();
218
256
  const missing: ResolvedBin[] = [];
219
257
  bins.forEach((bin, i) => {
220
258
  const p = presence[i];
221
259
  if (p?.status === "missing") missing.push(bin);
222
- if (p?.status === "off-path") {
223
- advice.set(p.prefix, pathAdviceFor(p.prefix, env));
224
- }
260
+ if (p?.status === "off-path") offPathPrefixes.add(p.prefix);
225
261
  });
226
262
 
227
263
  // A pin the owning plugin's option cannot carry: unfixable whatever the
@@ -325,11 +361,16 @@ export function planDeps(
325
361
  }
326
362
  steps.push(p);
327
363
  const prefix = prefixOf(p.step);
328
- if (prefix && !prefixOnPath(prefix, env)) {
329
- advice.set(prefix, pathAdviceFor(prefix, env));
330
- }
364
+ if (prefix && !prefixOnPath(prefix, env)) offPathPrefixes.add(prefix);
331
365
  }
332
- return { steps, unfixable, pathAdvice: [...advice.values()] };
366
+ // A prefix whose installer writes the rc line itself gets no line ahead of
367
+ // it: following one and then running the installer leaves the line twice.
368
+ const rcEdited = new Set(steps.map((p) => rcEditedBy(p.step)));
369
+ const pathAdvice = [...offPathPrefixes]
370
+ .filter((prefix) => !rcEdited.has(prefix))
371
+ .map((prefix) => pathAdviceFor(prefix, env))
372
+ .filter((a): a is PathAdvice => a !== null);
373
+ return { steps, unfixable, pathAdvice };
333
374
  }
334
375
 
335
376
  /**
@@ -397,23 +438,48 @@ export interface ApplyIO {
397
438
  }
398
439
 
399
440
  const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
441
+ /** Redirects a download follows before it gives up. */
442
+ const MAX_REDIRECTS = 10;
400
443
  /** The per-check cap a post-install re-probe runs under: doctor's. */
401
444
  const VERIFY_CAP_MS = 60_000;
402
445
 
446
+ /**
447
+ * The body at `url`, every hop of it over https. Redirects are followed here,
448
+ * one at a time, so a hop that is not https is refused before it is requested:
449
+ * a bootstrap script has no digest to catch what a plain-http hop could swap.
450
+ */
451
+ async function httpsDownload(url: string): Promise<Uint8Array> {
452
+ const signal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
453
+ let at = new URL(url);
454
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
455
+ if (at.protocol !== "https:") {
456
+ throw new Error(
457
+ hop === 0
458
+ ? `${url} is not https — refused`
459
+ : `${url} redirected off https, to ${at.href} — refused`,
460
+ );
461
+ }
462
+ const res = await fetch(at, { redirect: "manual", signal });
463
+ const location = res.headers.get("location");
464
+ if (res.status >= 300 && res.status < 400 && location) {
465
+ await res.body?.cancel();
466
+ at = new URL(location, at);
467
+ continue;
468
+ }
469
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${at.href}`);
470
+ // A fetch that followed a redirect itself reports where it ended up.
471
+ if (res.url && new URL(res.url).protocol !== "https:") {
472
+ throw new Error(`${url} redirected off https, to ${res.url} — refused`);
473
+ }
474
+ return new Uint8Array(await res.arrayBuffer());
475
+ }
476
+ throw new Error(`${url} redirected more than ${MAX_REDIRECTS} times`);
477
+ }
478
+
403
479
  export const REAL_APPLY_IO: ApplyIO = {
404
480
  run: (argv, opts) =>
405
481
  runArgv({ cmd: argv[0] ?? "", args: argv.slice(1) }, opts),
406
- download: async (url) => {
407
- const res = await fetch(url, {
408
- signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
409
- });
410
- if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
411
- // Every URL here is https; a redirect off it is refused, not read.
412
- if (new URL(res.url).protocol !== "https:") {
413
- throw new Error(`${url} redirected off https, to ${res.url}`);
414
- }
415
- return new Uint8Array(await res.arrayBuffer());
416
- },
482
+ download: httpsDownload,
417
483
  uid: () => (typeof process.getuid === "function" ? process.getuid() : null),
418
484
  alive: (pid) => {
419
485
  try {
@@ -452,11 +518,11 @@ export interface ApplyOptions {
452
518
  io?: Partial<ApplyIO>;
453
519
  }
454
520
 
455
- /** Another apply holds the lock. */
521
+ /** Another apply holds the lock; `pid` is null when its holder is not yet known. */
456
522
  export class DepsLockBusy extends Error {
457
- constructor(readonly pid: number) {
523
+ constructor(readonly pid: number | null) {
458
524
  super(
459
- `another magus dependency install (pid ${pid}) is running — wait for it, then re-run`,
525
+ `another magus dependency install${pid === null ? "" : ` (pid ${pid})`} is running — wait for it, then re-run`,
460
526
  );
461
527
  this.name = "DepsLockBusy";
462
528
  }
@@ -467,33 +533,140 @@ export function defaultLockPath(home: string, xdgCacheHome?: string): string {
467
533
  return join(xdgCacheHome || join(home, ".cache"), "magus", "doctor-fix.lock");
468
534
  }
469
535
 
470
- /** Take the lock (O_EXCL, holding our pid), taking over one whose pid is gone. */
471
- function acquireLock(path: string, io: ApplyIO): () => void {
536
+ /** How many times a busy lock is tried, and how long apart. */
537
+ const LOCK_ATTEMPTS = 20;
538
+ const LOCK_RETRY_MS = 50;
539
+ /**
540
+ * A lock with no pid in it this old was left by a writer that died between
541
+ * creating it and writing the pid; no live writer is that slow.
542
+ */
543
+ const EMPTY_LOCK_STALE_MS = 10_000;
544
+
545
+ const sleep = (ms: number) =>
546
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
547
+
548
+ /** What the lock file says about its holder. */
549
+ type LockHolder =
550
+ /** Released between the failed take and this read. */
551
+ | { kind: "gone" }
552
+ | { kind: "pid"; pid: number }
553
+ /** Created by a writer that has not written its pid yet, or never will. */
554
+ | { kind: "no-pid"; ageMs: number }
555
+ /** Not a regular file (a dangling symlink, say): no holder ever wrote it. */
556
+ | { kind: "not-a-lock" };
557
+
558
+ function readLockHolder(path: string): LockHolder {
559
+ const gone = (error: unknown): LockHolder => {
560
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
561
+ return { kind: "gone" };
562
+ }
563
+ throw error;
564
+ };
565
+ let mtimeMs: number;
566
+ try {
567
+ const stat = lstatSync(path);
568
+ if (!stat.isFile()) return { kind: "not-a-lock" };
569
+ mtimeMs = stat.mtimeMs;
570
+ } catch (error) {
571
+ return gone(error);
572
+ }
573
+ let text: string;
574
+ try {
575
+ text = readFileSync(path, "utf8").trim();
576
+ } catch (error) {
577
+ return gone(error);
578
+ }
579
+ const pid = Number.parseInt(text, 10);
580
+ return Number.isInteger(pid) && pid > 0
581
+ ? { kind: "pid", pid }
582
+ : { kind: "no-pid", ageMs: Date.now() - mtimeMs };
583
+ }
584
+
585
+ /** Remove a stale lock; one another taker removed first is just as gone. */
586
+ function removeStaleLock(path: string): void {
587
+ try {
588
+ unlinkSync(path);
589
+ } catch (error) {
590
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Take the lock. The pid is written to a file of our own first and the lock is
596
+ * a hard link to it, so the lock never exists without a pid in it. A live
597
+ * holder refuses with {@link DepsLockBusy}; one whose pid is gone is taken
598
+ * over. A lock that vanishes before it is read is free, and taken. A lock with
599
+ * no pid (another writer between creating and writing it) is busy: it is read
600
+ * again shortly, and taken over only once it is too old to be mid-write.
601
+ */
602
+ async function acquireLock(path: string, io: ApplyIO): Promise<() => void> {
472
603
  mkdirSync(dirname(path), { recursive: true });
473
- for (let attempt = 0; attempt < 2; attempt++) {
474
- try {
475
- const fd = openSync(path, "wx", 0o600);
476
- writeSync(fd, String(process.pid));
477
- closeSync(fd);
478
- return () => {
479
- try {
480
- if (readFileSync(path, "utf8").trim() === String(process.pid)) {
481
- unlinkSync(path);
604
+ const mine = String(process.pid);
605
+ const staged = `${path}.${mine}`;
606
+ writeFileSync(staged, mine, { mode: 0o600 });
607
+ try {
608
+ for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
609
+ try {
610
+ linkSync(staged, path);
611
+ return () => {
612
+ try {
613
+ if (readFileSync(path, "utf8").trim() === mine) unlinkSync(path);
614
+ } catch {
615
+ // Already gone.
482
616
  }
483
- } catch {
484
- // Already gone.
485
- }
486
- };
487
- } catch (error) {
488
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
489
- const pid = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
490
- if (Number.isInteger(pid) && pid > 0 && io.alive(pid)) {
491
- throw new DepsLockBusy(pid);
617
+ };
618
+ } catch (error) {
619
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
620
+ }
621
+ const holder = readLockHolder(path);
622
+ if (holder.kind === "gone") continue;
623
+ if (holder.kind === "not-a-lock") {
624
+ removeStaleLock(path);
625
+ continue;
626
+ }
627
+ if (holder.kind === "pid") {
628
+ if (io.alive(holder.pid)) throw new DepsLockBusy(holder.pid);
629
+ removeStaleLock(path); // its holder is gone
630
+ continue;
631
+ }
632
+ if (holder.ageMs > EMPTY_LOCK_STALE_MS) {
633
+ removeStaleLock(path);
634
+ continue;
635
+ }
636
+ await sleep(LOCK_RETRY_MS);
637
+ }
638
+ throw new DepsLockBusy(null);
639
+ } finally {
640
+ rmSync(staged, { force: true });
641
+ }
642
+ }
643
+
644
+ /** The temp dir a release step extracts into, inside its destination. */
645
+ const RELEASE_TMP_PREFIX = ".magus-tmp-";
646
+
647
+ /**
648
+ * Remove the temp dirs a release step left in a user prefix when its apply was
649
+ * killed before the step's `finally` ran. Runs holding the lock, so no apply
650
+ * owns any of them.
651
+ */
652
+ function sweepStaleTemp(home: string): void {
653
+ for (const prefix of USER_PREFIXES) {
654
+ const dir = expandHome(prefix.dir, home);
655
+ let entries: string[];
656
+ try {
657
+ entries = readdirSync(dir);
658
+ } catch {
659
+ continue; // no such prefix yet: nothing left behind
660
+ }
661
+ for (const entry of entries) {
662
+ if (!entry.startsWith(RELEASE_TMP_PREFIX)) continue;
663
+ try {
664
+ rmSync(join(dir, entry), { recursive: true, force: true });
665
+ } catch {
666
+ // One that cannot be removed leaves the rest to sweep.
492
667
  }
493
- unlinkSync(path); // stale: its holder is gone
494
668
  }
495
669
  }
496
- throw new Error(`could not take ${path}`);
497
670
  }
498
671
 
499
672
  const STEP_TIMEOUT_MS = 15 * 60_000;
@@ -544,8 +717,9 @@ export async function applyDepsPlan(
544
717
  if (plan.steps.length === 0) return [];
545
718
  if (!opts.yes && !(await opts.confirm(plan))) return [];
546
719
  const io: ApplyIO = { ...REAL_APPLY_IO, ...opts.io };
547
- const release = acquireLock(opts.lockPath, io);
720
+ const release = await acquireLock(opts.lockPath, io);
548
721
  try {
722
+ sweepStaleTemp(opts.env.home);
549
723
  return await applySteps(plan, opts, io);
550
724
  } finally {
551
725
  release();
@@ -618,11 +792,12 @@ async function applySteps(
618
792
  }
619
793
  opts.onEvent?.({ type: "step-start", name: step.name, describe });
620
794
  const started = Date.now();
621
- const run = (argv: string[], escalated = false) =>
795
+ const run = (argv: string[], how: RunHow = {}) =>
622
796
  io.run(argv, {
623
797
  timeoutMs: STEP_TIMEOUT_MS,
624
- env: childEnv,
625
- ...(escalated ? { killGraceMs: SUDO_KILL_GRACE_MS } : {}),
798
+ env: how.env ? { ...childEnv, ...how.env } : childEnv,
799
+ ...(how.escalated ? { killGraceMs: SUDO_KILL_GRACE_MS } : {}),
800
+ ...(how.captureStdout ? { captureStdout: true } : {}),
626
801
  onOutput: (chunk) =>
627
802
  opts.onEvent?.({ type: "output", name: step.name, chunk }),
628
803
  });
@@ -708,9 +883,10 @@ async function landingDir(
708
883
  step.argv[0] === "bun" &&
709
884
  step.argv[1] === "add"
710
885
  ) {
711
- const r = await ctx.run([ctx.resolve("bun"), "pm", "bin", "-g"]);
712
- const dir = r.exitCode === 0 ? r.outputTail.trim().split("\n").pop() : "";
713
- return dir?.trim() || null;
886
+ const r = await ctx.run([ctx.resolve("bun"), "pm", "bin", "-g"], {
887
+ captureStdout: true,
888
+ });
889
+ return r.exitCode === 0 ? lastLine(r.stdout) : null;
714
890
  }
715
891
  return null;
716
892
  }
@@ -750,12 +926,11 @@ async function verifyInstalled(
750
926
  }
751
927
  const first = offPath[0];
752
928
  if (first?.dir) {
753
- const line = pathRcLine(first.dir, env.userShell);
754
929
  return {
755
930
  status: "off-path",
756
931
  dir: first.dir,
757
932
  why: `installed in ${first.dir}, which is not on the PATH Claude Code starts with`,
758
- remedy: `add ${line} to ${rcFile(env.userShell)}, then restart Claude Code from a new terminal`,
933
+ remedy: pathRemedy(first.dir, env),
759
934
  };
760
935
  }
761
936
  return { status: "installed" };
@@ -768,10 +943,20 @@ interface StepResult {
768
943
  outputTail: string;
769
944
  }
770
945
 
946
+ /** How one argv of a step runs. */
947
+ interface RunHow {
948
+ /** Through sudo: SIGKILL cannot be relayed, so it gets a longer grace. */
949
+ escalated?: boolean;
950
+ /** Added to the child's environment. */
951
+ env?: Readonly<Record<string, string>>;
952
+ /** A query whose answer is its stdout: `RunResult.stdout` is set. */
953
+ captureStdout?: boolean;
954
+ }
955
+
771
956
  interface StepContext {
772
957
  env: PlanEnv;
773
958
  io: ApplyIO;
774
- run(argv: string[], escalated?: boolean): Promise<RunResult>;
959
+ run(argv: string[], how?: RunHow): Promise<RunResult>;
775
960
  escalate(argv: string[]): string[];
776
961
  root: boolean;
777
962
  resolve(tool: string): string;
@@ -784,15 +969,25 @@ const fromRun = (argv: string[], r: RunResult): StepResult => ({
784
969
  outputTail: r.spawnError ?? r.outputTail,
785
970
  });
786
971
 
972
+ /** The last non-empty line of a query's stdout, or null. */
973
+ function lastLine(stdout: string | undefined): string | null {
974
+ const line = (stdout ?? "")
975
+ .split("\n")
976
+ .map((l) => l.trim())
977
+ .filter(Boolean)
978
+ .pop();
979
+ return line || null;
980
+ }
981
+
787
982
  /** Run argvs in order, stopping at the first that fails. */
788
983
  async function sequence(
789
984
  ctx: StepContext,
790
985
  argvs: string[][],
791
- escalated: boolean,
986
+ how: RunHow = {},
792
987
  ): Promise<StepResult> {
793
988
  let last: StepResult = { ok: true, exitCode: 0, outputTail: "" };
794
989
  for (const argv of argvs) {
795
- last = fromRun(argv, await ctx.run(argv, escalated));
990
+ last = fromRun(argv, await ctx.run(argv, how));
796
991
  if (!last.ok) return last;
797
992
  }
798
993
  return last;
@@ -814,14 +1009,12 @@ async function runStep(
814
1009
  ctx.escalate(["apt-get", ...APT_LOCK, "update"]),
815
1010
  install,
816
1011
  ],
817
- ctx.root,
1012
+ { escalated: ctx.root },
818
1013
  );
819
1014
  }
820
- return sequence(
821
- ctx,
822
- [ctx.escalate([ctx.resolve(tool), ...args])],
823
- ctx.root,
824
- );
1015
+ return sequence(ctx, [ctx.escalate([ctx.resolve(tool), ...args])], {
1016
+ escalated: ctx.root,
1017
+ });
825
1018
  }
826
1019
  case "uv-module":
827
1020
  return runUvModule(step, ctx);
@@ -839,17 +1032,14 @@ async function runUvModule(
839
1032
  ctx: StepContext,
840
1033
  ): Promise<StepResult> {
841
1034
  const uv = ctx.resolve("uv");
842
- const sync = await sequence(
843
- ctx,
844
- [uvScriptArgv(["sync"], step.script, uv)],
845
- false,
846
- );
1035
+ const sync = await sequence(ctx, [uvScriptArgv(["sync"], step.script, uv)]);
847
1036
  if (!sync.ok) return sync;
848
1037
  const findArgv = uvScriptArgv(["python", "find"], step.script, uv);
849
- const found = await ctx.run(findArgv);
1038
+ const found = await ctx.run(findArgv, { captureStdout: true });
850
1039
  const findResult = fromRun(findArgv, found);
851
1040
  if (!findResult.ok) return findResult;
852
- const py = found.outputTail.trim().split("\n").pop()?.trim() ?? "";
1041
+ // The interpreter is what uv prints on stdout; a warning goes to stderr.
1042
+ const py = lastLine(found.stdout);
853
1043
  if (!py) {
854
1044
  return {
855
1045
  ...findResult,
@@ -869,7 +1059,7 @@ async function runUvModule(
869
1059
  return sequence(
870
1060
  ctx,
871
1061
  [ctx.escalate([py, "-B", "-m", step.module, ...step.args])],
872
- ctx.root,
1062
+ { escalated: ctx.root },
873
1063
  );
874
1064
  }
875
1065
 
@@ -882,7 +1072,9 @@ async function runBootstrap(
882
1072
  try {
883
1073
  const file = join(dir, "install.sh");
884
1074
  writeFileSync(file, body, { mode: 0o600 });
885
- return await sequence(ctx, [[ctx.resolve(step.interpreter), file]], false);
1075
+ return await sequence(ctx, [[ctx.resolve(step.interpreter), file]], {
1076
+ ...(step.env ? { env: step.env } : {}),
1077
+ });
886
1078
  } finally {
887
1079
  rmSync(dir, { recursive: true, force: true });
888
1080
  }
@@ -919,7 +1111,7 @@ async function runRelease(
919
1111
  }
920
1112
  const dest = expandHome(step.dest, ctx.env.home);
921
1113
  mkdirSync(dest, { recursive: true });
922
- const tmp = mkdtempSync(join(dest, ".magus-tmp-"));
1114
+ const tmp = mkdtempSync(join(dest, RELEASE_TMP_PREFIX));
923
1115
  try {
924
1116
  if (step.format === "binary") {
925
1117
  // The asset is the binary: the verified bytes are the file.
@@ -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 {