magus-cli 7.9.0 → 7.9.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.
@@ -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/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");