claudeup 6.0.0 → 6.2.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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `runShellScript` has exactly one legitimate caller, and this pins it.
3
+ *
4
+ * The whole of item 3 is the split between two ways of running a child process:
5
+ * argv (`runCommand`), which no shell ever sees, and a script (`runShellScript`),
6
+ * which `/bin/sh` parses. Every installer command belongs to the first, because
7
+ * it is assembled from package names that arrive in `.claude/profiles.json` and
8
+ * plugin `requires.bin` — hand-authored, committed by teammates, fetched from
9
+ * marketplaces, validated by nothing. The second exists only for the two
10
+ * `TOOLCHAIN_BOOTSTRAP` values, which are constants in this repo copied from
11
+ * bun.sh and brew.sh and which genuinely contain a pipe and a command
12
+ * substitution.
13
+ *
14
+ * Nothing in the type system distinguishes them — both are strings — so after
15
+ * the commit lands, the design decision is enforced by this test or by nothing.
16
+ * A branded `ShellScript` type would enforce it at compile time and was
17
+ * considered; for one map, two entries and one call site it is more machinery
18
+ * than the rule is worth, and unlike a brand this is greppable.
19
+ *
20
+ * Source-scanning precedent in this suite: `uppercase-keybindings.test.ts`.
21
+ */
22
+
23
+ import { describe, expect, test } from "bun:test";
24
+ import path from "node:path";
25
+ import fs from "fs-extra";
26
+
27
+ const SRC_DIR = path.join(import.meta.dir, "..");
28
+
29
+ /** Files permitted to call `runShellScript`, relative to `src/`. */
30
+ const ALLOWED = ["cli/install.ts"];
31
+
32
+ /** The definition itself, plus this test, are not callers. */
33
+ const NOT_CALLERS = ["utils/run.ts", "__tests__/shell-script-callers.test.ts"];
34
+
35
+ async function sourceFiles(dir: string): Promise<string[]> {
36
+ const out: string[] = [];
37
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
38
+ const full = path.join(dir, entry.name);
39
+ if (entry.isDirectory()) out.push(...(await sourceFiles(full)));
40
+ else if (/\.tsx?$/.test(entry.name)) out.push(full);
41
+ }
42
+ return out;
43
+ }
44
+
45
+ const isComment = (line: string) => {
46
+ const t = line.trim();
47
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
48
+ };
49
+
50
+ async function callersOf(pattern: RegExp): Promise<string[]> {
51
+ const found = new Set<string>();
52
+ for (const file of await sourceFiles(SRC_DIR)) {
53
+ const rel = path.relative(SRC_DIR, file);
54
+ if (NOT_CALLERS.includes(rel)) continue;
55
+ const text = await fs.readFile(file, "utf8");
56
+ for (const line of text.split("\n")) {
57
+ if (isComment(line)) continue;
58
+ if (pattern.test(line)) found.add(rel);
59
+ }
60
+ }
61
+ return [...found].sort();
62
+ }
63
+
64
+ describe("only the toolchain bootstrap goes through a shell", () => {
65
+ test("runShellScript is called from exactly the allowed files", async () => {
66
+ expect(await callersOf(/\brunShellScript\(/)).toEqual(ALLOWED);
67
+ });
68
+
69
+ test("nothing spawns with `shell: true` outside utils/run.ts", async () => {
70
+ // Catches someone reaching for `spawn`/`exec` directly rather than
71
+ // through the seam — a second shell path that never names
72
+ // `runShellScript`.
73
+ //
74
+ // Scope, stated honestly: this checks `shell: true` only. Several
75
+ // version-DETECTION helpers in CliToolsScreen still pass
76
+ // `shell: "/bin/bash"` to run pipelines like `which -a x 2>/dev/null`.
77
+ // Those interpolate a catalogue `name`, not an installer command, and
78
+ // were out of scope here — so this assertion would be false if it
79
+ // claimed no shell exists anywhere.
80
+ expect(await callersOf(/shell:\s*true/)).toEqual([]);
81
+ });
82
+
83
+ test("the scan can actually fail", async () => {
84
+ // Negative control. The two assertions above pass trivially if the walk
85
+ // finds nothing or the pattern never matches, and a check that cannot
86
+ // fail is not evidence. `runCommand(` is known to be called from several
87
+ // files, so a working scan must find them.
88
+ const runCommandCallers = await callersOf(/\brunCommand\(/);
89
+ expect(runCommandCallers.length).toBeGreaterThan(0);
90
+ expect(runCommandCallers).toContain("cli/install.ts");
91
+ });
92
+ });
@@ -1,34 +1,101 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ PYTHON_CLI_INSTALLERS,
4
+ type PythonCliInstaller,
3
5
  binInstallCommand,
4
6
  binUpgradeCommand,
5
7
  detectToolchains,
8
+ resolvePythonCliInstaller,
6
9
  } from "../services/toolchain.js";
7
10
  import type { ResolvedBin } from "../types/index.js";
11
+ import { formatCommand } from "../utils/command-utils.js";
12
+
13
+ /**
14
+ * Pin a Python installer explicitly in every `via: "pip"` assertion.
15
+ *
16
+ * The production default is resolved from PATH, so asserting a literal command
17
+ * would encode whatever happens to be installed on the machine running the
18
+ * suite — green here, red on a runner without uv.
19
+ */
20
+ const byProbe = (probe: string): PythonCliInstaller => {
21
+ const found = PYTHON_CLI_INSTALLERS.find((i) => i.probe === probe);
22
+ if (!found) throw new Error(`no python installer with probe ${probe}`);
23
+ return found;
24
+ };
25
+ const UV = byProbe("uv");
26
+ const PIPX = byProbe("pipx");
27
+ const PY = byProbe("python3");
8
28
 
9
29
  describe("binInstallCommand", () => {
10
30
  test("bun with and without a version pin", () => {
11
- expect(binInstallCommand({ name: "claudish", via: "bun", package: "claudish", version: "1.2.0" })).toBe(
12
- "bun install -g claudish@1.2.0",
13
- );
14
- expect(binInstallCommand({ name: "claudish", via: "bun" })).toBe(
15
- "bun install -g claudish",
16
- );
31
+ expect(
32
+ formatCommand(
33
+ binInstallCommand({
34
+ name: "claudish",
35
+ via: "bun",
36
+ package: "claudish",
37
+ version: "1.2.0",
38
+ }),
39
+ ),
40
+ ).toBe("bun install -g claudish@1.2.0");
41
+ expect(
42
+ formatCommand(binInstallCommand({ name: "claudish", via: "bun" })),
43
+ ).toBe("bun install -g claudish");
44
+ });
45
+
46
+ test("the package name is ONE argv token, whatever is in it", () => {
47
+ // `bin.package` is hand-authored data — it arrives from
48
+ // `.claude/profiles.json` and from plugin `requires.bin`, committed by
49
+ // teammates and fetched from marketplaces, and nothing validates it.
50
+ // While commands were strings handed to `/bin/sh`, this string was four
51
+ // words and a command separator. As argv it cannot be anything but a
52
+ // (nonexistent) package name, so the install fails instead of running.
53
+ const evil = "a b; rm -rf /";
54
+ const cmd = binInstallCommand({ name: "x", via: "bun", package: evil });
55
+ expect(cmd.cmd).toBe("bun");
56
+ expect(cmd.args).toEqual(["install", "-g", evil]);
57
+ expect(cmd.args.at(-1)).toBe(evil); // unsplit: still exactly one token
58
+ // ...and rendering it for display quotes it rather than emitting syntax.
59
+ expect(formatCommand(cmd)).toBe("bun install -g 'a b; rm -rf /'");
60
+ });
61
+
62
+ test("a version pin joins the package as one token, not two", () => {
63
+ // Splitting `pkg@version` would ask the installer for two packages, the
64
+ // second of which is a version number.
65
+ const cmd = binInstallCommand({
66
+ name: "claudish",
67
+ via: "bun",
68
+ version: "1.2.0",
69
+ });
70
+ expect(cmd.args.at(-1)).toBe("claudish@1.2.0");
17
71
  });
18
72
 
19
73
  test("npm, pip, brew, go each render correctly", () => {
20
- expect(binInstallCommand({ name: "x", via: "npm", package: "x" })).toBe(
21
- "npm install -g x",
22
- );
23
- expect(binInstallCommand({ name: "aider", via: "pip", package: "aider", version: "0.1" })).toBe(
24
- "pip install aider==0.1",
25
- );
26
- expect(binInstallCommand({ name: "tmux", via: "brew", formula: "tmux" })).toBe(
27
- "brew install tmux",
28
- );
29
- expect(binInstallCommand({ name: "t", via: "go", module: "github.com/x/t" })).toBe(
30
- "go install github.com/x/t@latest",
31
- );
74
+ // Rendered, not raw, because these literals are also the display-
75
+ // neutrality claim: every token here is shell-safe, so `formatCommand`
76
+ // quotes nothing and the printed line is byte-identical to the string
77
+ // these builders returned before they returned argv.
78
+ expect(
79
+ formatCommand(binInstallCommand({ name: "x", via: "npm", package: "x" })),
80
+ ).toBe("npm install -g x");
81
+ expect(
82
+ formatCommand(
83
+ binInstallCommand(
84
+ { name: "aider", via: "pip", package: "aider", version: "0.1" },
85
+ UV,
86
+ ),
87
+ ),
88
+ ).toBe("uv tool install aider==0.1");
89
+ expect(
90
+ formatCommand(
91
+ binInstallCommand({ name: "tmux", via: "brew", formula: "tmux" }),
92
+ ),
93
+ ).toBe("brew install tmux");
94
+ expect(
95
+ formatCommand(
96
+ binInstallCommand({ name: "t", via: "go", module: "github.com/x/t" }),
97
+ ),
98
+ ).toBe("go install github.com/x/t@latest");
32
99
  });
33
100
  });
34
101
 
@@ -38,26 +105,104 @@ describe("binUpgradeCommand", () => {
38
105
  // installed formula prints "already installed" and changes nothing, and
39
106
  // `pip install` without --upgrade does the same. Reusing them would let
40
107
  // `claudeup update` report success while advancing nothing.
41
- expect(binUpgradeCommand({ name: "tmux", via: "brew", formula: "tmux" })).toBe(
42
- "brew upgrade tmux",
43
- );
44
- expect(binUpgradeCommand({ name: "aider", via: "pip", package: "aider" })).toBe(
45
- "pip install --upgrade aider",
46
- );
108
+ expect(
109
+ formatCommand(
110
+ binUpgradeCommand({ name: "tmux", via: "brew", formula: "tmux" }),
111
+ ),
112
+ ).toBe("brew upgrade tmux");
113
+ expect(
114
+ formatCommand(
115
+ binUpgradeCommand({ name: "aider", via: "pip", package: "aider" }, PY),
116
+ ),
117
+ ).toBe("python3 -m pip install --user --upgrade aider");
118
+ expect(
119
+ formatCommand(
120
+ binUpgradeCommand({ name: "aider", via: "pip", package: "aider" }, UV),
121
+ ),
122
+ ).toBe("uv tool install --force --upgrade aider");
123
+ });
124
+
125
+ test("every Python installer distinguishes install from upgrade", () => {
126
+ // The same trap as brew: `uv tool install` on an already-installed tool
127
+ // is a no-op, so reusing the install command would report success while
128
+ // advancing nothing.
129
+ for (const python of PYTHON_CLI_INSTALLERS) {
130
+ const bin = { name: "aider", via: "pip" as const, package: "aider" };
131
+ // Compared RENDERED. Two `Command` objects are never `toBe`-equal —
132
+ // they are distinct references — so comparing them directly would be
133
+ // an assertion that cannot fail.
134
+ expect(formatCommand(binUpgradeCommand(bin, python))).not.toBe(
135
+ formatCommand(binInstallCommand(bin, python)),
136
+ );
137
+ }
138
+ });
139
+ });
140
+
141
+ describe("resolvePythonCliInstaller", () => {
142
+ test("never emits a bare `pip`", () => {
143
+ // The bug this exists for: `pip` is frequently a zsh ALIAS for pip3 and
144
+ // no such executable exists. Commands were spawned through /bin/sh, which
145
+ // sources no profile, so this died with `/bin/sh: pip: command not found`
146
+ // on a machine where typing `pip` at a prompt works.
147
+ //
148
+ // Now that a command is argv, `cmd` is the exact executable that gets
149
+ // looked up on PATH — so asserting on it is asserting on the syscall,
150
+ // not on a string a shell might later re-read.
151
+ for (const python of PYTHON_CLI_INSTALLERS) {
152
+ for (const cmd of [python.install("x"), python.upgrade("x")]) {
153
+ expect(cmd.cmd).not.toBe("pip");
154
+ expect(cmd.cmd).toBe(python.probe);
155
+ }
156
+ }
157
+ });
158
+
159
+ test("prefers an isolated-venv installer over the system interpreter", () => {
160
+ // `pip install` into a Homebrew interpreter is refused outright by PEP
161
+ // 668 (`externally-managed-environment`), so uv and pipx must outrank it.
162
+ const order = PYTHON_CLI_INSTALLERS.map((i) => i.probe);
163
+ expect(order.indexOf("uv")).toBeLessThan(order.indexOf("python3"));
164
+ expect(order.indexOf("pipx")).toBeLessThan(order.indexOf("python3"));
165
+ });
166
+
167
+ test("always resolves to something, and to one of the known options", () => {
168
+ // Returning null would force every caller to handle "no installer",
169
+ // which in practice means printing nothing and silently skipping.
170
+ const resolved = resolvePythonCliInstaller();
171
+ expect(PYTHON_CLI_INSTALLERS).toContain(resolved);
172
+ });
173
+
174
+ test("a version pin is expressed in the installer's own syntax", () => {
175
+ expect(
176
+ formatCommand(
177
+ binInstallCommand({ name: "a", via: "pip", version: "1.2" }, UV),
178
+ ),
179
+ ).toBe("uv tool install a==1.2");
180
+ expect(
181
+ formatCommand(
182
+ binInstallCommand({ name: "a", via: "pip", version: "1.2" }, PIPX),
183
+ ),
184
+ ).toBe("pipx install a==1.2");
185
+ // `a==1.2` is one token. Two would ask uv to install a package called
186
+ // "1.2".
187
+ expect(
188
+ binInstallCommand({ name: "a", via: "pip", version: "1.2" }, UV).args,
189
+ ).toEqual(["tool", "install", "a==1.2"]);
47
190
  });
48
191
 
49
192
  test("bun and npm state @latest explicitly, so the printed command reads true", () => {
50
- expect(binUpgradeCommand({ name: "claudish", via: "bun" })).toBe(
51
- "bun install -g claudish@latest",
52
- );
53
- expect(binUpgradeCommand({ name: "x", via: "npm", package: "x" })).toBe(
54
- "npm install -g x@latest",
55
- );
193
+ expect(
194
+ formatCommand(binUpgradeCommand({ name: "claudish", via: "bun" })),
195
+ ).toBe("bun install -g claudish@latest");
196
+ expect(
197
+ formatCommand(binUpgradeCommand({ name: "x", via: "npm", package: "x" })),
198
+ ).toBe("npm install -g x@latest");
56
199
  });
57
200
 
58
201
  test("go installs the module at latest", () => {
59
202
  expect(
60
- binUpgradeCommand({ name: "t", via: "go", module: "github.com/x/t" }),
203
+ formatCommand(
204
+ binUpgradeCommand({ name: "t", via: "go", module: "github.com/x/t" }),
205
+ ),
61
206
  ).toBe("go install github.com/x/t@latest");
62
207
  });
63
208
  });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The UI records a plugin version only through a function that read it back.
3
+ *
4
+ * `installedPluginVersions` is claudeup's own bookkeeping — the Claude CLI does
5
+ * not maintain it — and `install --check` reads it to decide whether a machine
6
+ * has drifted from its profile. So a version written from an EXPECTATION rather
7
+ * than from disk is not a cosmetic inaccuracy: the drift gate ends up comparing
8
+ * a number against a copy of itself and reporting clean forever. That is what
9
+ * PluginsScreen's install branches did, via a local `saveVersionForScope`
10
+ * helper that duplicated `plugin-manager.ts`'s scope dispatcher and took the
11
+ * version as a parameter.
12
+ *
13
+ * Deleting the helper removed the UI's *ability* to make that mistake. This
14
+ * test is what keeps it removed: a screen may still install and update, but
15
+ * only through `installPluginInScope`, which reads the version off disk and
16
+ * writes nothing when it cannot.
17
+ *
18
+ * A source scan rather than a behavioural test because these are `.tsx` screens
19
+ * this suite cannot render. It encodes the rule directly instead of the symptom
20
+ * — the precedent for source-scanning guards here is
21
+ * `uppercase-keybindings.test.ts`.
22
+ */
23
+
24
+ import { describe, expect, test } from "bun:test";
25
+ import path from "node:path";
26
+ import fs from "fs-extra";
27
+
28
+ const UI_DIR = path.join(import.meta.dir, "..", "ui");
29
+
30
+ /** The version writers that take the number on trust from their caller. */
31
+ const RAW_WRITERS = [
32
+ "saveGlobalInstalledPluginVersion",
33
+ "saveLocalInstalledPluginVersion",
34
+ "saveInstalledPluginVersion",
35
+ "saveInstalledPluginVersionForScope",
36
+ ];
37
+
38
+ async function uiSourceFiles(dir: string): Promise<string[]> {
39
+ const out: string[] = [];
40
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
41
+ const full = path.join(dir, entry.name);
42
+ if (entry.isDirectory()) out.push(...(await uiSourceFiles(full)));
43
+ else if (/\.tsx?$/.test(entry.name)) out.push(full);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ const isComment = (line: string) => {
49
+ const t = line.trim();
50
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
51
+ };
52
+
53
+ describe("no UI file writes a plugin version it did not read back", () => {
54
+ test("the raw version writers are absent from src/ui", async () => {
55
+ const files = await uiSourceFiles(UI_DIR);
56
+ expect(files.length).toBeGreaterThan(10); // guard: the walk found real files
57
+
58
+ const offenders: string[] = [];
59
+ for (const file of files) {
60
+ const text = await fs.readFile(file, "utf8");
61
+ text.split("\n").forEach((line, i) => {
62
+ if (isComment(line)) return;
63
+ for (const writer of RAW_WRITERS) {
64
+ // Word-bounded: `saveInstalledPluginVersion` is a prefix of
65
+ // `saveInstalledPluginVersionForScope`, and both are banned, so a
66
+ // substring match would just double-report the same line.
67
+ if (new RegExp(`\\b${writer}\\b`).test(line)) {
68
+ offenders.push(
69
+ `${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
70
+ );
71
+ }
72
+ }
73
+ });
74
+ }
75
+
76
+ expect(offenders).toEqual([]);
77
+ });
78
+
79
+ test("the guard can actually fail", () => {
80
+ // Negative control. The assertion above passes trivially if the walk or
81
+ // the pattern is broken, and a guard that cannot fail is not evidence.
82
+ const line = "\t\t\tawait saveGlobalInstalledPluginVersion(id, version);";
83
+ expect(isComment(line)).toBe(false);
84
+ expect(RAW_WRITERS.some((w) => new RegExp(`\\b${w}\\b`).test(line))).toBe(
85
+ true,
86
+ );
87
+ });
88
+ });
@@ -7,7 +7,12 @@
7
7
  */
8
8
 
9
9
  import { beforeEach, describe, expect, mock, test } from "bun:test";
10
- import { type PluginApplyDeps, applyPlugins } from "../cli/update.js";
10
+ import {
11
+ type PluginApplyDeps,
12
+ applyPlugins,
13
+ installPluginInScope,
14
+ updatePluginInScope,
15
+ } from "../services/update-engine.js";
11
16
  import type { PluginScope } from "../services/claude-cli.js";
12
17
  import type { PluginUpdateItem } from "../services/update-plan.js";
13
18
 
@@ -18,10 +23,14 @@ function stubDeps(
18
23
  installedAfter: string | null | ((scope: PluginScope) => string | null),
19
24
  ) {
20
25
  const saved: Array<{ id: string; version: string; scope: PluginScope }> = [];
26
+ const installed: Array<{ id: string; scope: PluginScope }> = [];
21
27
  const updated: Array<{ id: string; scope: PluginScope }> = [];
22
28
  const repaired: Array<{ id: string; scope: PluginScope }> = [];
23
29
 
24
30
  const deps: PluginApplyDeps = {
31
+ install: mock(async (id: string, scope: PluginScope) => {
32
+ installed.push({ id, scope });
33
+ }),
25
34
  update: mock(async (id: string, scope: PluginScope) => {
26
35
  updated.push({ id, scope });
27
36
  }),
@@ -39,7 +48,7 @@ function stubDeps(
39
48
  },
40
49
  ),
41
50
  };
42
- return { deps, saved, updated, repaired };
51
+ return { deps, saved, installed, updated, repaired };
43
52
  }
44
53
 
45
54
  function item(over: Partial<PluginUpdateItem>): PluginUpdateItem {
@@ -128,13 +137,77 @@ describe("what gets RECORDED is what got installed", () => {
128
137
 
129
138
  describe("scope handling", () => {
130
139
  test("a fresh install goes to project scope only", async () => {
131
- const { deps, updated } = stubDeps("2.0.0");
140
+ const { deps, installed } = stubDeps("2.0.0");
132
141
  await applyPlugins(
133
142
  [item({ action: "install", installed: null, scopes: [] })],
134
143
  PROJECT,
135
144
  deps,
136
145
  );
146
+ expect(installed).toEqual([{ id: "dev@magus", scope: "project" }]);
147
+ });
148
+
149
+ // Three actions, three Claude Code commands, each a silent no-op in the
150
+ // other two cases. Folding `install` and `update` together is what made
151
+ // every claudeup update path exit 0 having changed nothing: measured on
152
+ // Claude Code 2.1.252, `claude plugin install designer@magus --scope
153
+ // project` answers "already installed" and leaves 0.6.0 in place while
154
+ // 0.6.1 is published, and `claude plugin update` is the command that moves
155
+ // it. The reverse holds too — `update` on a plugin absent from the scope
156
+ // answers "already at the latest version" and installs nothing — so this
157
+ // asserts BOTH directions, not just the one that was broken.
158
+
159
+ test("an update runs `update`, never `install`", async () => {
160
+ const { deps, installed, updated } = stubDeps("2.0.0");
161
+ await applyPlugins([item({ action: "update" })], PROJECT, deps);
137
162
  expect(updated).toEqual([{ id: "dev@magus", scope: "project" }]);
163
+ expect(installed).toEqual([]);
164
+ });
165
+
166
+ test("an update with NO row for this project installs instead", async () => {
167
+ // `claude plugin update --scope project` resolves the project from the
168
+ // working directory, and with no row there it updates some OTHER
169
+ // project's row instead. Measured on 2.1.252 from a directory holding no
170
+ // madbench row, it moved /Users/jack/mag/tmux-mcp — a project nobody
171
+ // named. Live for git worktrees, which inherit the parent repo's version
172
+ // while owning no row of their own.
173
+ const { deps, installed, updated } = stubDeps(null);
174
+ await applyPlugins([item({ action: "update" })], PROJECT, deps);
175
+ expect(installed).toEqual([{ id: "dev@magus", scope: "project" }]);
176
+ expect(updated).toEqual([]);
177
+ });
178
+
179
+ test("the row check is per scope, not per plugin", async () => {
180
+ // user scope holds a row, project scope does not: one must update and
181
+ // the other install, in the same pass.
182
+ const { deps, installed, updated } = stubDeps((scope) =>
183
+ scope === "user" ? "1.5.0" : null,
184
+ );
185
+ await applyPlugins([item({ scopes: ["user", "project"] })], PROJECT, deps);
186
+ expect(updated).toEqual([{ id: "dev@magus", scope: "user" }]);
187
+ expect(installed).toEqual([{ id: "dev@magus", scope: "project" }]);
188
+ });
189
+
190
+ test("a fresh install runs `install`, never `update`", async () => {
191
+ const { deps, installed, updated } = stubDeps("2.0.0");
192
+ await applyPlugins(
193
+ [item({ action: "install", installed: null, scopes: [] })],
194
+ PROJECT,
195
+ deps,
196
+ );
197
+ expect(installed).toEqual([{ id: "dev@magus", scope: "project" }]);
198
+ expect(updated).toEqual([]);
199
+ });
200
+
201
+ test("a repair runs neither — content drift needs uninstall+install", async () => {
202
+ const { deps, installed, updated, repaired } = stubDeps("1.0.0");
203
+ await applyPlugins(
204
+ [item({ action: "repair", target: "1.0.0", scopes: ["project"] })],
205
+ PROJECT,
206
+ deps,
207
+ );
208
+ expect(repaired).toEqual([{ id: "dev@magus", scope: "project" }]);
209
+ expect(installed).toEqual([]);
210
+ expect(updated).toEqual([]);
138
211
  });
139
212
 
140
213
  test("every outdated scope is updated AND recorded separately", async () => {
@@ -221,3 +294,64 @@ describe("failure handling", () => {
221
294
  expect(result.ok).toBe(0);
222
295
  });
223
296
  });
297
+
298
+ describe("installPluginInScope — a fresh install records what landed", () => {
299
+ // The TUI's install branches called the CLI directly and then wrote the
300
+ // version they had ASKED for. A marketplace serves whatever it publishes at
301
+ // the moment of the call, so that number was a guess — and `install --check`
302
+ // reads back the same field, so it compared the guess against a copy of
303
+ // itself and reported clean forever. Routing installs through the read-back
304
+ // the update branches already used is what closes it.
305
+ //
306
+ // The read-back semantics are pinned in cli-apply-seams.test.ts
307
+ // ("installPluginInScope — the scope-targeted path"). What these add is the
308
+ // property that makes the whole class of bug unrepresentable rather than
309
+ // merely absent right now.
310
+
311
+ test("takes no version parameter, so a caller cannot supply a wrong one", () => {
312
+ // Arity, not behaviour — and that is the point. Re-adding a `version`
313
+ // argument would reopen the defect without failing any behavioural test,
314
+ // because the new parameter would simply be believed.
315
+ // (pluginId, scope, projectPath, deps = REAL_PLUGIN_DEPS) — the defaulted
316
+ // parameter is not counted by Function.length.
317
+ expect(installPluginInScope.length).toBe(3);
318
+ });
319
+
320
+ test("saves the version read back, not one the caller chose", async () => {
321
+ const { deps, saved } = stubDeps("9.9.9");
322
+ await installPluginInScope("dev@magus", "user", PROJECT, deps);
323
+ expect(saved).toEqual([
324
+ { id: "dev@magus", version: "9.9.9", scope: "user" },
325
+ ]);
326
+ });
327
+
328
+ test("an unreadable version writes nothing at all", async () => {
329
+ // Never guess. A missing record is recoverable; a wrong one is not,
330
+ // because nothing downstream can tell it from a true one.
331
+ const { deps, saved } = stubDeps(null);
332
+ expect(
333
+ await installPluginInScope("dev@magus", "user", PROJECT, deps),
334
+ ).toBeNull();
335
+ expect(saved).toEqual([]);
336
+ });
337
+
338
+ // The scope menu's update branch went through the install helper for as
339
+ // long as both issued `claude plugin install`. They no longer do, so the
340
+ // two helpers must reach different commands or the menu silently no-ops
341
+ // exactly the way "update all" did.
342
+ test("the install helper runs `install`, the update helper runs `update`", async () => {
343
+ const a = stubDeps("2.0.0");
344
+ await installPluginInScope("dev@magus", "user", PROJECT, a.deps);
345
+ expect(a.installed).toEqual([{ id: "dev@magus", scope: "user" }]);
346
+ expect(a.updated).toEqual([]);
347
+
348
+ const b = stubDeps("2.0.0");
349
+ await updatePluginInScope("dev@magus", "user", PROJECT, b.deps);
350
+ expect(b.updated).toEqual([{ id: "dev@magus", scope: "user" }]);
351
+ expect(b.installed).toEqual([]);
352
+ });
353
+
354
+ test("the update helper takes no version parameter either", () => {
355
+ expect(updatePluginInScope.length).toBe(3);
356
+ });
357
+ });
@@ -47,9 +47,10 @@ import type {
47
47
  ResolvedClosure,
48
48
  SkillInfo,
49
49
  } from "../types/index.js";
50
- import { resolveExecutable } from "../utils/command-utils.js";
50
+ import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
51
51
  import { ensureManifest } from "./bootstrap.js";
52
- import { confirm, promptValue, runShell } from "./prompt.js";
52
+ import { confirm, promptValue } from "./prompt.js";
53
+ import { runCommand, runShellScript } from "../utils/run.js";
53
54
 
54
55
  interface InstallFlags {
55
56
  check: boolean;
@@ -125,7 +126,10 @@ async function ensureToolchains(
125
126
  ));
126
127
  if (ok) {
127
128
  console.log(`Installing ${tc.name}…`);
128
- if (!(await runShell(bootstrap))) {
129
+ // A bootstrap line IS shell text — bun's contains a pipe, Homebrew's a
130
+ // command substitution — and it is a constant in this repo, not
131
+ // profile data. This is the only legitimate caller.
132
+ if (!(await runShellScript(bootstrap))) {
129
133
  console.warn(`⚠ Failed to install ${tc.name}. Continuing.`);
130
134
  }
131
135
  }
@@ -163,8 +167,10 @@ async function installBins(bins: ResolvedBin[]): Promise<void> {
163
167
  continue; // already installed and runnable (not a dangling link)
164
168
  }
165
169
  const cmd = binInstallCommand(bin);
166
- console.log(`+ ${bin.name} (${cmd})`);
167
- if (!(await runShell(cmd))) {
170
+ // Printed rendered, executed as argv. The package name in `cmd.args` came
171
+ // from a profile or a plugin manifest, so it must never reach a shell.
172
+ console.log(`+ ${bin.name} (${formatCommand(cmd)})`);
173
+ if (!(await runCommand(cmd))) {
168
174
  console.warn(`⚠ failed to install ${bin.name} via ${bin.via}`);
169
175
  }
170
176
  }
package/src/cli/prompt.ts CHANGED
@@ -31,12 +31,3 @@ export async function promptValue(question: string): Promise<string> {
31
31
  }
32
32
  }
33
33
 
34
- /** Run a shell command, streaming output; resolve true on exit 0. */
35
- export async function runShell(command: string): Promise<boolean> {
36
- const { spawn } = await import("node:child_process");
37
- return new Promise((resolve) => {
38
- const child = spawn(command, { stdio: "inherit", shell: true });
39
- child.on("exit", (code) => resolve(code === 0));
40
- child.on("error", () => resolve(false));
41
- });
42
- }
@@ -32,6 +32,7 @@ import type {
32
32
  } from "../services/update-plan.js";
33
33
  import { binInstallCommand, binUpgradeCommand } from "../services/toolchain.js";
34
34
  import { brand } from "../ui/theme.js";
35
+ import { formatCommand } from "../utils/command-utils.js";
35
36
  import {
36
37
  badge,
37
38
  bold,
@@ -256,7 +257,9 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
256
257
  item.action === "install"
257
258
  ? binInstallCommand(spec)
258
259
  : binUpgradeCommand(spec);
259
- return dim(cmd);
260
+ // Rendered for the reader's eyes only — this row says what WILL run, while
261
+ // applyBins runs the argv itself.
262
+ return dim(formatCommand(cmd));
260
263
  }
261
264
 
262
265
  /**