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,133 @@
1
+ /**
2
+ * The three commands the CLI-tools screen can run against a catalogue entry:
3
+ * install, update, uninstall.
4
+ *
5
+ * A module rather than helpers inside the screen, for two reasons:
6
+ *
7
+ * - **The duplicate.** Uninstall existed twice — once in `CliToolsScreen.tsx`
8
+ * and once in `cliToolRenderers.tsx` (as `getUninstallHint`) — because the
9
+ * renderer needs to SHOW it and the screen needs to RUN it. The two copies
10
+ * had already drifted: one emitted `pip uninstall`, the other
11
+ * `pip uninstall -y`. Both were wrong in the same way, which is the point —
12
+ * a bug in a duplicated builder has to be found twice.
13
+ * - **Testability.** As `.tsx` helpers these were unreachable from a suite that
14
+ * cannot render React. As pure functions over data they are pinned by
15
+ * `cli-tool-commands.test.ts`, which is what proves no arm emits a bare
16
+ * `pip` — the defect that shipped.
17
+ *
18
+ * Import direction stays acyclic: renderer → service, screen → service +
19
+ * renderer.
20
+ */
21
+
22
+ import type { CliTool } from "../data/cli-tools.js";
23
+ import type { Command } from "../utils/command-utils.js";
24
+ import {
25
+ binInstallCommand,
26
+ binUninstallCommand,
27
+ binUpgradeCommand,
28
+ } from "./toolchain.js";
29
+
30
+ /**
31
+ * How a tool turned out to be installed, as detected from its path on disk.
32
+ *
33
+ * NOT the same question as `CliTool.packageManager`, and deliberately not the
34
+ * same type. `packageManager` is how claudeup WOULD install the tool; this is
35
+ * how it IS installed — which may be neither, since a user can `pnpm add -g` or
36
+ * `yarn global add` something the catalogue calls an npm package. Updating has
37
+ * to follow what is actually on the machine, or it installs a second copy
38
+ * beside the first and the screen then reports a conflict it created itself.
39
+ *
40
+ * `pnpm`, `yarn` and `unknown` have no `BinInstaller` counterpart, which is why
41
+ * the two unions stay separate rather than one being widened into the other.
42
+ */
43
+ export type InstallMethod =
44
+ | "npm"
45
+ | "bun"
46
+ | "pnpm"
47
+ | "yarn"
48
+ | "brew"
49
+ | "pip"
50
+ | "unknown";
51
+
52
+ /**
53
+ * Install a catalogue tool, derived from the entry's own fields.
54
+ *
55
+ * Entries used to carry a literal `installCommand` string beside the
56
+ * `packageManager` and `packageName` that describe the same thing, so the two
57
+ * could disagree — and did. `aider` declared `packageName: "aider-chat"` and
58
+ * `checkCommand: "aider --version"`, and its `installCommand` installed
59
+ * `aider-install`: a bootstrapper whose version nothing in this screen ever
60
+ * looked at, while the version check queried `aider-chat`. Deriving makes that
61
+ * disagreement unrepresentable rather than merely fixed.
62
+ */
63
+ export function cliToolInstall(tool: CliTool): Command {
64
+ return binInstallCommand({
65
+ name: tool.name,
66
+ via: tool.packageManager,
67
+ package: tool.packageName,
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Update a tool, following the method it was ACTUALLY installed with.
73
+ *
74
+ * Returns null when there is nothing sound to run — an `unknown` method means
75
+ * the detector could not attribute the binary on PATH to any package manager,
76
+ * and guessing there would install a second copy alongside the one already
77
+ * there. Callers fall back to installing.
78
+ */
79
+ export function cliToolUpdate(
80
+ tool: CliTool,
81
+ method: InstallMethod,
82
+ brewFormula?: string,
83
+ ): Command | null {
84
+ switch (method) {
85
+ // The three the resolver does not model. `pnpm`/`yarn` are not
86
+ // BinInstallers because claudeup never chooses them — it only finds them.
87
+ case "pnpm":
88
+ return { cmd: "pnpm", args: ["install", "-g", tool.packageName] };
89
+ case "yarn":
90
+ return { cmd: "yarn", args: ["global", "add", tool.packageName] };
91
+ case "unknown":
92
+ return null;
93
+ default:
94
+ // TS has narrowed to "npm" | "bun" | "brew" | "pip" here, which is
95
+ // assignable to BinInstaller — so the four real installers delegate,
96
+ // and `pip` in particular gets the uv/pipx/python3 resolution instead
97
+ // of the bare `pip` this switch used to fall through to.
98
+ return binUpgradeCommand({
99
+ name: tool.name,
100
+ via: method,
101
+ package: tool.packageName,
102
+ formula: brewFormula,
103
+ });
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Remove a tool, following the method it was actually installed with.
109
+ *
110
+ * Null means "no command to offer": `unknown` for the reason above, and `go`
111
+ * because a `go install` binary has no uninstaller to call.
112
+ */
113
+ export function cliToolUninstall(
114
+ tool: CliTool,
115
+ method: InstallMethod,
116
+ brewFormula?: string,
117
+ ): Command | null {
118
+ switch (method) {
119
+ case "pnpm":
120
+ return { cmd: "pnpm", args: ["remove", "-g", tool.packageName] };
121
+ case "yarn":
122
+ return { cmd: "yarn", args: ["global", "remove", tool.packageName] };
123
+ case "unknown":
124
+ return null;
125
+ default:
126
+ return binUninstallCommand({
127
+ name: tool.name,
128
+ via: method,
129
+ package: tool.packageName,
130
+ formula: brewFormula,
131
+ });
132
+ }
133
+ }
@@ -8,13 +8,19 @@
8
8
  */
9
9
 
10
10
  import type { ResolvedBin } from "../types/index.js";
11
- import { resolveExecutable } from "../utils/command-utils.js";
11
+ import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
12
12
  import { binInstallCommand } from "./toolchain.js";
13
13
 
14
14
  export interface BinCheckResult {
15
15
  name: string;
16
16
  present: boolean;
17
- /** The command that would install it, shown when missing. */
17
+ /**
18
+ * The command that would install it, shown when missing.
19
+ *
20
+ * A rendered string, not a {@link Command}: this is advisory text printed
21
+ * after "run:" for a human to copy. Nothing executes it — doctor diagnoses
22
+ * and never installs — so there is nothing here for a shell to re-parse.
23
+ */
18
24
  installCommand: string;
19
25
  /** Where the requirement came from (profile cliTools / plugin ids). */
20
26
  sources: string[];
@@ -44,7 +50,7 @@ export async function checkBinaries(
44
50
  results.push({
45
51
  name: bin.name,
46
52
  present: await isPresent(bin.name),
47
- installCommand: binInstallCommand(bin),
53
+ installCommand: formatCommand(binInstallCommand(bin)),
48
54
  sources: bin.sources,
49
55
  });
50
56
  }
@@ -9,17 +9,157 @@
9
9
  */
10
10
 
11
11
  import type { BinInstaller, ResolvedBin } from "../types/index.js";
12
- import { which } from "../utils/command-utils.js";
12
+ import type { Command } from "../utils/command-utils.js";
13
+ import { which, whichSync } from "../utils/command-utils.js";
13
14
 
14
- /** The executable that proves a toolchain is installed. */
15
- const TOOLCHAIN_PROBE: Record<BinInstaller, string> = {
15
+ // -- Python CLI applications -------------------------------------------------
16
+
17
+ /**
18
+ * How to install a Python COMMAND-LINE APPLICATION on this machine.
19
+ *
20
+ * `via: "pip"` used to emit the literal string `pip install <pkg>`, which is
21
+ * wrong on a modern macOS in two independent ways, both measured:
22
+ *
23
+ * 1. **`pip` frequently does not exist.** Homebrew installs `pip3`, and many
24
+ * people reach it through a shell alias — which lives only in interactive
25
+ * zsh. Commands were spawned through `/bin/sh`, which never sources a
26
+ * profile, so this died with `/bin/sh: pip: command not found` on a machine
27
+ * where typing `pip` at a prompt works perfectly. Commands are argv now
28
+ * (`utils/run.ts`), so `cmd` is looked up on PATH with no shell at all —
29
+ * which makes "resolve a real executable" the only option rather than the
30
+ * careful one.
31
+ * 2. **`pip install` into a Homebrew interpreter is refused outright.** PEP
32
+ * 668 marks it externally-managed; `python3 -m pip install --upgrade x`
33
+ * fails with `externally-managed-environment` no matter how it is spelled.
34
+ *
35
+ * So the fix is not "spell pip correctly". For a CLI application — which is
36
+ * what every `via: "pip"` requirement in this repo is, a package whose point is
37
+ * the executable it puts on PATH — installing into the system interpreter is
38
+ * the wrong mechanism. `uv tool` and `pipx` each give the package its own
39
+ * virtualenv and link the executable onto PATH, which is both what was wanted
40
+ * and immune to PEP 668.
41
+ *
42
+ * Ordered best-first. `python3 -m pip --user` is last because it is the one
43
+ * that PEP 668 can still reject; it stays because it is correct on Linux, in CI
44
+ * and under pyenv, and because emitting it names `python3` in the failure —
45
+ * which is a true and actionable error — rather than a `pip` that never existed.
46
+ */
47
+ export interface PythonCliInstaller {
48
+ /** Executable that must be on PATH for this option to be usable. */
49
+ probe: string;
50
+ install(pkg: string, version?: string): Command;
51
+ upgrade(pkg: string): Command;
52
+ /**
53
+ * Remove the tool again.
54
+ *
55
+ * Installer-specific for the same reason install is: a package installed
56
+ * with `uv tool install` lives in uv's own virtualenv and `pip uninstall`
57
+ * cannot see it. The one form that is always wrong is a bare `pip`, which
58
+ * frequently is not an executable at all.
59
+ */
60
+ uninstall(pkg: string): Command;
61
+ }
62
+
63
+ export const PYTHON_CLI_INSTALLERS: readonly PythonCliInstaller[] = [
64
+ {
65
+ probe: "uv",
66
+ install: (pkg, version) => ({
67
+ cmd: "uv",
68
+ // `pkg==version` is ONE argv token. Splitting on the `==` would ask uv
69
+ // to install two packages, one of which is a version number.
70
+ args: ["tool", "install", version ? `${pkg}==${version}` : pkg],
71
+ }),
72
+ // `uv tool install --force --upgrade`, NOT `uv tool upgrade`. The latter
73
+ // only knows tools uv itself installed and errors otherwise —
74
+ // `browser-use is not installed; run uv tool install` on a machine where
75
+ // the executable was plainly on PATH, put there by a pip that predates
76
+ // uv. Since `claudeup update` reaches here for anything already present,
77
+ // that is the common case, not the edge case. `--force` adopts the
78
+ // existing shim; `--upgrade` moves it to the newest version.
79
+ upgrade: (pkg) => ({
80
+ cmd: "uv",
81
+ args: ["tool", "install", "--force", "--upgrade", pkg],
82
+ }),
83
+ uninstall: (pkg) => ({ cmd: "uv", args: ["tool", "uninstall", pkg] }),
84
+ },
85
+ {
86
+ probe: "pipx",
87
+ install: (pkg, version) => ({
88
+ cmd: "pipx",
89
+ args: ["install", version ? `${pkg}==${version}` : pkg],
90
+ }),
91
+ // Same reasoning as uv: `pipx upgrade` fails when pipx does not already
92
+ // manage the package.
93
+ upgrade: (pkg) => ({ cmd: "pipx", args: ["install", "--force", pkg] }),
94
+ uninstall: (pkg) => ({ cmd: "pipx", args: ["uninstall", pkg] }),
95
+ },
96
+ {
97
+ probe: "python3",
98
+ install: (pkg, version) => ({
99
+ cmd: "python3",
100
+ args: [
101
+ "-m",
102
+ "pip",
103
+ "install",
104
+ "--user",
105
+ version ? `${pkg}==${version}` : pkg,
106
+ ],
107
+ }),
108
+ upgrade: (pkg) => ({
109
+ cmd: "python3",
110
+ args: ["-m", "pip", "install", "--user", "--upgrade", pkg],
111
+ }),
112
+ // `-y`: nothing here is attached to a TTY that could answer a prompt.
113
+ uninstall: (pkg) => ({
114
+ cmd: "python3",
115
+ args: ["-m", "pip", "uninstall", "-y", pkg],
116
+ }),
117
+ },
118
+ ];
119
+
120
+ /** Memoised: PATH does not change within one claudeup run. */
121
+ let pythonCliMemo: PythonCliInstaller | undefined;
122
+
123
+ /**
124
+ * The best Python CLI installer available here. Never null — the `python3`
125
+ * entry is returned when nothing better is found, so callers always get a
126
+ * command whose failure names something real.
127
+ */
128
+ export function resolvePythonCliInstaller(): PythonCliInstaller {
129
+ if (pythonCliMemo) return pythonCliMemo;
130
+ pythonCliMemo =
131
+ PYTHON_CLI_INSTALLERS.find((i) => whichSync(i.probe) !== null) ??
132
+ PYTHON_CLI_INSTALLERS.at(-1)!;
133
+ return pythonCliMemo;
134
+ }
135
+
136
+ /** Test seam — PATH is stable within a run, so production never needs this. */
137
+ export function resetPythonCliInstallerMemo(): void {
138
+ pythonCliMemo = undefined;
139
+ }
140
+
141
+ // ----------------------------------------------------------------------------
142
+
143
+ /**
144
+ * The executable that proves a toolchain is installed.
145
+ *
146
+ * `pip` is deliberately absent: which executable proves it depends on the
147
+ * machine, so it is resolved by {@link resolvePythonCliInstaller} instead.
148
+ */
149
+ const TOOLCHAIN_PROBE: Record<Exclude<BinInstaller, "pip">, string> = {
16
150
  bun: "bun",
17
151
  brew: "brew",
18
152
  npm: "npm",
19
- pip: "pip",
20
153
  go: "go",
21
154
  };
22
155
 
156
+ /** The executable whose presence proves `name` is usable. */
157
+ function toolchainProbe(name: BinInstaller): string {
158
+ return name === "pip"
159
+ ? resolvePythonCliInstaller().probe
160
+ : TOOLCHAIN_PROBE[name];
161
+ }
162
+
23
163
  /** Official one-line bootstrap for the toolchains claudeup will install. */
24
164
  export const TOOLCHAIN_BOOTSTRAP: Partial<Record<BinInstaller, string>> = {
25
165
  bun: "curl -fsSL https://bun.sh/install | bash",
@@ -37,7 +177,7 @@ export interface ToolchainStatus {
37
177
 
38
178
  /** Is a given toolchain available on PATH? */
39
179
  export async function isToolchainPresent(name: BinInstaller): Promise<boolean> {
40
- return (await which(TOOLCHAIN_PROBE[name])) !== null;
180
+ return (await which(toolchainProbe(name))) !== null;
41
181
  }
42
182
 
43
183
  /**
@@ -69,30 +209,48 @@ export async function detectToolchains(
69
209
  return statuses;
70
210
  }
71
211
 
72
- /** The install command for a single binary requirement. */
73
- export function binInstallCommand(bin: {
74
- name: string;
75
- via: BinInstaller;
76
- package?: string;
77
- formula?: string;
78
- module?: string;
79
- version?: string;
80
- }): string {
212
+ /**
213
+ * The install command for a single binary requirement.
214
+ *
215
+ * `python` is injectable so tests can pin one and stay machine-independent —
216
+ * the default depends on what is on PATH, so an assertion against a literal
217
+ * `uv tool …` would pass here and fail on a runner without uv.
218
+ *
219
+ * `pkg@version` and `pkg==version` are each ONE argv token. That join is
220
+ * precisely where a string was dangerous: `bin.package` is hand-authored
221
+ * profile/marketplace data, and under a shell a space or a `;` in it would have
222
+ * become extra words. Here it cannot.
223
+ */
224
+ export function binInstallCommand(
225
+ bin: {
226
+ name: string;
227
+ via: BinInstaller;
228
+ package?: string;
229
+ formula?: string;
230
+ module?: string;
231
+ version?: string;
232
+ },
233
+ python: PythonCliInstaller = resolvePythonCliInstaller(),
234
+ ): Command {
81
235
  const pkg = bin.package ?? bin.name;
82
236
  const at = bin.version ? `@${bin.version}` : "";
83
237
  switch (bin.via) {
84
238
  case "bun":
85
- return `bun install -g ${pkg}${at}`;
239
+ return { cmd: "bun", args: ["install", "-g", `${pkg}${at}`] };
86
240
  case "npm":
87
- return `npm install -g ${pkg}${at}`;
241
+ return { cmd: "npm", args: ["install", "-g", `${pkg}${at}`] };
88
242
  case "pip":
89
- return bin.version
90
- ? `pip install ${pkg}==${bin.version}`
91
- : `pip install ${pkg}`;
243
+ return python.install(pkg, bin.version);
92
244
  case "brew":
93
- return `brew install ${bin.formula ?? bin.name}`;
245
+ return { cmd: "brew", args: ["install", bin.formula ?? bin.name] };
94
246
  case "go":
95
- return `go install ${bin.module ?? bin.name}@${bin.version ?? "latest"}`;
247
+ return {
248
+ cmd: "go",
249
+ args: [
250
+ "install",
251
+ `${bin.module ?? bin.name}@${bin.version ?? "latest"}`,
252
+ ],
253
+ };
96
254
  }
97
255
  }
98
256
 
@@ -109,24 +267,68 @@ export function binInstallCommand(bin: {
109
267
  * for unpinned requirements; `@latest` is stated explicitly where the installer
110
268
  * accepts it, so the intent survives in the printed command.
111
269
  */
112
- export function binUpgradeCommand(bin: {
113
- name: string;
114
- via: BinInstaller;
115
- package?: string;
116
- formula?: string;
117
- module?: string;
118
- }): string {
270
+ export function binUpgradeCommand(
271
+ bin: {
272
+ name: string;
273
+ via: BinInstaller;
274
+ package?: string;
275
+ formula?: string;
276
+ module?: string;
277
+ },
278
+ python: PythonCliInstaller = resolvePythonCliInstaller(),
279
+ ): Command {
280
+ const pkg = bin.package ?? bin.name;
281
+ switch (bin.via) {
282
+ case "bun":
283
+ return { cmd: "bun", args: ["install", "-g", `${pkg}@latest`] };
284
+ case "npm":
285
+ return { cmd: "npm", args: ["install", "-g", `${pkg}@latest`] };
286
+ case "pip":
287
+ return python.upgrade(pkg);
288
+ case "brew":
289
+ return { cmd: "brew", args: ["upgrade", bin.formula ?? bin.name] };
290
+ case "go":
291
+ return {
292
+ cmd: "go",
293
+ args: ["install", `${bin.module ?? bin.name}@latest`],
294
+ };
295
+ }
296
+ }
297
+
298
+ /**
299
+ * The command that REMOVES an installed binary.
300
+ *
301
+ * Third of the trio, and it needs the resolver for exactly the reason the other
302
+ * two do: which executable can remove a Python CLI application depends on which
303
+ * one installed it, and a bare `pip uninstall` is wrong on the same machines
304
+ * where a bare `pip install` was. The CLI-tools screen carried that bare form in
305
+ * two separate copies before this existed.
306
+ *
307
+ * `brew` and `go` have no catalogue entries today, so those arms ship
308
+ * unexercised by real data — they are pinned by unit test instead. `go` has no
309
+ * uninstall at all (`go install` writes a binary into GOBIN and records
310
+ * nothing), so it returns null rather than inventing an `rm`.
311
+ */
312
+ export function binUninstallCommand(
313
+ bin: {
314
+ name: string;
315
+ via: BinInstaller;
316
+ package?: string;
317
+ formula?: string;
318
+ },
319
+ python: PythonCliInstaller = resolvePythonCliInstaller(),
320
+ ): Command | null {
119
321
  const pkg = bin.package ?? bin.name;
120
322
  switch (bin.via) {
121
323
  case "bun":
122
- return `bun install -g ${pkg}@latest`;
324
+ return { cmd: "bun", args: ["remove", "-g", pkg] };
123
325
  case "npm":
124
- return `npm install -g ${pkg}@latest`;
326
+ return { cmd: "npm", args: ["uninstall", "-g", pkg] };
125
327
  case "pip":
126
- return `pip install --upgrade ${pkg}`;
328
+ return python.uninstall(pkg);
127
329
  case "brew":
128
- return `brew upgrade ${bin.formula ?? bin.name}`;
330
+ return { cmd: "brew", args: ["uninstall", bin.formula ?? bin.name] };
129
331
  case "go":
130
- return `go install ${bin.module ?? bin.name}@latest`;
332
+ return null;
131
333
  }
132
334
  }