claudeup 5.0.1 → 6.1.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.
- package/package.json +4 -4
- package/scripts/test-isolated.ts +7 -1
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +281 -0
- package/src/__tests__/cli-live.test.ts +384 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-update-view.test.ts +286 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/gitignore-fixer.test.ts +20 -1
- package/src/__tests__/marketplace-refresh.test.ts +63 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/toolchain.test.ts +176 -31
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +46 -1
- package/src/cli/ansi.ts +512 -0
- package/src/cli/bootstrap.ts +0 -6
- package/src/cli/install.ts +15 -6
- package/src/cli/live.ts +374 -0
- package/src/cli/profile.ts +1 -1
- package/src/cli/prompt.ts +0 -9
- package/src/cli/router.ts +7 -3
- package/src/cli/update-view.ts +441 -0
- package/src/cli/update.ts +357 -298
- package/src/data/cli-tools.ts +20 -13
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/doctor-bins.ts +18 -4
- package/src/services/marketplace-refresh.ts +39 -1
- package/src/services/toolchain.ts +235 -33
- package/src/services/update-engine.ts +412 -0
- package/src/ui/renderers/cliToolRenderers.tsx +40 -39
- package/src/ui/screens/CliToolsScreen.tsx +56 -64
- package/src/ui/screens/PluginsScreen.tsx +119 -64
- package/src/utils/command-utils.ts +102 -1
- package/src/utils/run.ts +67 -0
package/src/data/cli-tools.ts
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
|
+
import type { BinInstaller } from "../types/index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A CLI tool claudeup can install, check and update.
|
|
5
|
+
*
|
|
6
|
+
* There is deliberately NO `installCommand` field. Entries used to carry one
|
|
7
|
+
* alongside `packageManager` + `packageName`, which describe the same thing —
|
|
8
|
+
* so the two could disagree, and did: `aider` declared
|
|
9
|
+
* `packageName: "aider-chat"` while its `installCommand` installed
|
|
10
|
+
* `aider-install`, and the version check queried the former. The command is
|
|
11
|
+
* derived by `services/cli-tool-commands.ts` instead, which also means a
|
|
12
|
+
* catalogue entry can no longer smuggle shell syntax (that same entry's command
|
|
13
|
+
* was a `&&` chain) into something that gets executed.
|
|
14
|
+
*/
|
|
1
15
|
export interface CliTool {
|
|
2
16
|
name: string;
|
|
3
17
|
displayName: string;
|
|
4
18
|
description: string;
|
|
5
|
-
installCommand: string;
|
|
6
19
|
checkCommand: string;
|
|
7
20
|
website: string;
|
|
8
21
|
category: "ai-coding" | "utility";
|
|
9
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Reuses `BinInstaller` rather than restating its members, so the
|
|
24
|
+
* correspondence with the resolver in `services/toolchain.ts` — which is
|
|
25
|
+
* what turns this field into an actual command — is a compile-time fact.
|
|
26
|
+
*/
|
|
27
|
+
packageManager: BinInstaller;
|
|
10
28
|
packageName: string;
|
|
11
29
|
}
|
|
12
30
|
|
|
@@ -16,7 +34,6 @@ export const cliTools: CliTool[] = [
|
|
|
16
34
|
displayName: "claudeup",
|
|
17
35
|
description:
|
|
18
36
|
"TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
19
|
-
installCommand: "bun install -g claudeup",
|
|
20
37
|
checkCommand: "claudeup --version",
|
|
21
38
|
website: "https://github.com/MadAppGang/magus/tree/main/tools/claudeup",
|
|
22
39
|
category: "ai-coding",
|
|
@@ -28,7 +45,6 @@ export const cliTools: CliTool[] = [
|
|
|
28
45
|
displayName: "Mnemex",
|
|
29
46
|
description:
|
|
30
47
|
"AST-aware code search with PageRank, callers/callees, and semantic embeddings",
|
|
31
|
-
installCommand: "bun install -g mnemex",
|
|
32
48
|
checkCommand: "mnemex --version",
|
|
33
49
|
website: "https://github.com/MadAppGang/mnemex",
|
|
34
50
|
category: "ai-coding",
|
|
@@ -39,7 +55,6 @@ export const cliTools: CliTool[] = [
|
|
|
39
55
|
name: "claudish",
|
|
40
56
|
displayName: "Claudish",
|
|
41
57
|
description: "Run Claude Code with OpenRouter models (Grok, GPT-5, Gemini)",
|
|
42
|
-
installCommand: "bun install -g claudish",
|
|
43
58
|
checkCommand: "claudish --version",
|
|
44
59
|
website: "https://github.com/MadAppGang/claudish",
|
|
45
60
|
category: "ai-coding",
|
|
@@ -50,7 +65,6 @@ export const cliTools: CliTool[] = [
|
|
|
50
65
|
name: "claude",
|
|
51
66
|
displayName: "Claude Code",
|
|
52
67
|
description: "Anthropic official agentic coding tool",
|
|
53
|
-
installCommand: "npm install -g @anthropic-ai/claude-code",
|
|
54
68
|
checkCommand: "claude --version",
|
|
55
69
|
website: "https://claude.ai/code",
|
|
56
70
|
category: "ai-coding",
|
|
@@ -62,7 +76,6 @@ export const cliTools: CliTool[] = [
|
|
|
62
76
|
displayName: "OpenAI Codex",
|
|
63
77
|
description:
|
|
64
78
|
"Lightweight coding agent from OpenAI that runs in your terminal",
|
|
65
|
-
installCommand: "npm install -g @openai/codex",
|
|
66
79
|
checkCommand: "codex --version",
|
|
67
80
|
website: "https://github.com/openai/codex",
|
|
68
81
|
category: "ai-coding",
|
|
@@ -73,7 +86,6 @@ export const cliTools: CliTool[] = [
|
|
|
73
86
|
name: "gemini",
|
|
74
87
|
displayName: "Gemini CLI",
|
|
75
88
|
description: "Google AI agent with 1M token context, free tier available",
|
|
76
|
-
installCommand: "npm install -g @google/gemini-cli",
|
|
77
89
|
checkCommand: "gemini --version",
|
|
78
90
|
website: "https://github.com/google-gemini/gemini-cli",
|
|
79
91
|
category: "ai-coding",
|
|
@@ -84,7 +96,6 @@ export const cliTools: CliTool[] = [
|
|
|
84
96
|
name: "qwen",
|
|
85
97
|
displayName: "Qwen Code",
|
|
86
98
|
description: "Alibaba coding agent optimized for Qwen3-Coder models",
|
|
87
|
-
installCommand: "npm install -g @qwen-code/qwen-code",
|
|
88
99
|
checkCommand: "qwen --version",
|
|
89
100
|
website: "https://github.com/QwenLM/qwen-code",
|
|
90
101
|
category: "ai-coding",
|
|
@@ -96,7 +107,6 @@ export const cliTools: CliTool[] = [
|
|
|
96
107
|
displayName: "Cline",
|
|
97
108
|
description:
|
|
98
109
|
"Autonomous coding agent with plan & act, checkpoints, browser use",
|
|
99
|
-
installCommand: "npm install -g cline",
|
|
100
110
|
checkCommand: "cline --version",
|
|
101
111
|
website: "https://cline.bot",
|
|
102
112
|
category: "ai-coding",
|
|
@@ -107,7 +117,6 @@ export const cliTools: CliTool[] = [
|
|
|
107
117
|
name: "opencode",
|
|
108
118
|
displayName: "OpenCode",
|
|
109
119
|
description: "Open source AI coding agent trusted by 400k+ developers",
|
|
110
|
-
installCommand: "npm install -g opencode-ai",
|
|
111
120
|
checkCommand: "opencode --version",
|
|
112
121
|
website: "https://opencode.ai",
|
|
113
122
|
category: "ai-coding",
|
|
@@ -118,7 +127,6 @@ export const cliTools: CliTool[] = [
|
|
|
118
127
|
name: "aider",
|
|
119
128
|
displayName: "Aider",
|
|
120
129
|
description: "AI pair programming - works with Claude, GPT-4, local models",
|
|
121
|
-
installCommand: "pip install aider-install && aider-install",
|
|
122
130
|
checkCommand: "aider --version",
|
|
123
131
|
website: "https://aider.chat",
|
|
124
132
|
category: "ai-coding",
|
|
@@ -129,7 +137,6 @@ export const cliTools: CliTool[] = [
|
|
|
129
137
|
name: "crush",
|
|
130
138
|
displayName: "Crush",
|
|
131
139
|
description: "AI coding assistant from Charm with terminal-native UX",
|
|
132
|
-
installCommand: "npm install -g @charmland/crush",
|
|
133
140
|
checkCommand: "crush --version",
|
|
134
141
|
website: "https://github.com/charmbracelet/crush",
|
|
135
142
|
category: "ai-coding",
|
|
@@ -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
|
-
/**
|
|
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[];
|
|
@@ -23,6 +29,12 @@ export interface BinCheckResult {
|
|
|
23
29
|
/**
|
|
24
30
|
* Check each resolved binary for presence on PATH. `isPresent` is injectable
|
|
25
31
|
* for tests; it defaults to a real PATH lookup.
|
|
32
|
+
*
|
|
33
|
+
* The probes are deliberately SERIAL — a PATH walk per binary, each resolving
|
|
34
|
+
* symlinks — so a profile declaring a dozen tools spends real time here. That
|
|
35
|
+
* is what `onProbe` is for: it fires with the name about to be checked, before
|
|
36
|
+
* the await, so a caller can name the binary it is currently blocked on rather
|
|
37
|
+
* than showing a bare spinner.
|
|
26
38
|
*/
|
|
27
39
|
export async function checkBinaries(
|
|
28
40
|
bins: ResolvedBin[],
|
|
@@ -30,13 +42,15 @@ export async function checkBinaries(
|
|
|
30
42
|
// (the tmux-mcp / npx-cache failure mode) reads as MISSING, not present.
|
|
31
43
|
isPresent: (name: string) => Promise<boolean> = async (name) =>
|
|
32
44
|
(await resolveExecutable(name)) !== null,
|
|
45
|
+
onProbe?: (name: string, index: number, total: number) => void,
|
|
33
46
|
): Promise<BinCheckResult[]> {
|
|
34
47
|
const results: BinCheckResult[] = [];
|
|
35
|
-
for (const bin of bins) {
|
|
48
|
+
for (const [index, bin] of bins.entries()) {
|
|
49
|
+
onProbe?.(bin.name, index, bins.length);
|
|
36
50
|
results.push({
|
|
37
51
|
name: bin.name,
|
|
38
52
|
present: await isPresent(bin.name),
|
|
39
|
-
installCommand: binInstallCommand(bin),
|
|
53
|
+
installCommand: formatCommand(binInstallCommand(bin)),
|
|
40
54
|
sources: bin.sources,
|
|
41
55
|
});
|
|
42
56
|
}
|
|
@@ -130,12 +130,44 @@ export async function fastForwardCloneIfPresent(
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Progress taps, so a caller can show what is happening during the pulls.
|
|
135
|
+
*
|
|
136
|
+
* This is the slowest thing on the update path — a git fetch per marketplace,
|
|
137
|
+
* over the network, with a 30s timeout each — and it used to report only after
|
|
138
|
+
* every clone had settled.
|
|
139
|
+
*
|
|
140
|
+
* Both callbacks are purely observational and CANNOT affect the refresh: each
|
|
141
|
+
* is invoked inside a try/catch that discards whatever it throws. `onSettled`
|
|
142
|
+
* in particular runs inside the `Promise.all` fan-out, so an unguarded throw
|
|
143
|
+
* there would reject the batch and turn a display error into a failed refresh —
|
|
144
|
+
* exactly the coupling this function's "never throws" contract exists to
|
|
145
|
+
* prevent.
|
|
146
|
+
*/
|
|
147
|
+
export interface RefreshProgress {
|
|
148
|
+
/** The eligible set, once selection is done and before any pull starts. */
|
|
149
|
+
onStart?: (names: string[]) => void;
|
|
150
|
+
/** One clone settled. Fires in completion order, not in `names` order. */
|
|
151
|
+
onSettled?: (name: string, status: CloneRefresh) => void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Invoke an observer without letting it affect the caller. */
|
|
155
|
+
function tap(fn: (() => void) | undefined): void {
|
|
156
|
+
if (!fn) return;
|
|
157
|
+
try {
|
|
158
|
+
fn();
|
|
159
|
+
} catch {
|
|
160
|
+
// A progress display is never worth failing a refresh over.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
133
164
|
/**
|
|
134
165
|
* Non-destructively fast-forward the on-disk clone of every registered,
|
|
135
166
|
* auto-update-enabled, github-sourced marketplace.
|
|
136
167
|
*
|
|
137
168
|
* @param skip marketplaces already refreshed this run (e.g. re-registered ones),
|
|
138
169
|
* so they aren't pulled twice.
|
|
170
|
+
* @param progress optional taps for a live progress display.
|
|
139
171
|
* @returns which marketplaces were refreshed, which failed (clone left intact),
|
|
140
172
|
* and which were skipped. Failures and skips are collected rather than thrown —
|
|
141
173
|
* one unreachable marketplace must not block launching Claude. Pulls run
|
|
@@ -143,6 +175,7 @@ export async function fastForwardCloneIfPresent(
|
|
|
143
175
|
*/
|
|
144
176
|
export async function refreshRegisteredMarketplaces(
|
|
145
177
|
skip: Iterable<string> = [],
|
|
178
|
+
progress: RefreshProgress = {},
|
|
146
179
|
): Promise<RefreshResult> {
|
|
147
180
|
const refreshed: string[] = [];
|
|
148
181
|
const failed: string[] = [];
|
|
@@ -170,8 +203,13 @@ export async function refreshRegisteredMarketplaces(
|
|
|
170
203
|
// Fast-forward every eligible clone in parallel so a slow one doesn't
|
|
171
204
|
// serialize the rest onto the launch path. fastForwardCloneIfPresent never
|
|
172
205
|
// throws, so the batch always resolves.
|
|
206
|
+
tap(() => progress.onStart?.([...eligible]));
|
|
173
207
|
const statuses = await Promise.all(
|
|
174
|
-
eligible.map((name) =>
|
|
208
|
+
eligible.map(async (name) => {
|
|
209
|
+
const status = await fastForwardCloneIfPresent(name);
|
|
210
|
+
tap(() => progress.onSettled?.(name, status));
|
|
211
|
+
return status;
|
|
212
|
+
}),
|
|
175
213
|
);
|
|
176
214
|
|
|
177
215
|
statuses.forEach((status, i) => {
|
|
@@ -9,17 +9,157 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { BinInstaller, ResolvedBin } from "../types/index.js";
|
|
12
|
-
import {
|
|
12
|
+
import type { Command } from "../utils/command-utils.js";
|
|
13
|
+
import { which, whichSync } from "../utils/command-utils.js";
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
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(
|
|
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
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
239
|
+
return { cmd: "bun", args: ["install", "-g", `${pkg}${at}`] };
|
|
86
240
|
case "npm":
|
|
87
|
-
return
|
|
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
|
|
245
|
+
return { cmd: "brew", args: ["install", bin.formula ?? bin.name] };
|
|
94
246
|
case "go":
|
|
95
|
-
return
|
|
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(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
324
|
+
return { cmd: "bun", args: ["remove", "-g", pkg] };
|
|
123
325
|
case "npm":
|
|
124
|
-
return
|
|
326
|
+
return { cmd: "npm", args: ["uninstall", "-g", pkg] };
|
|
125
327
|
case "pip":
|
|
126
|
-
return
|
|
328
|
+
return python.uninstall(pkg);
|
|
127
329
|
case "brew":
|
|
128
|
-
return
|
|
330
|
+
return { cmd: "brew", args: ["uninstall", bin.formula ?? bin.name] };
|
|
129
331
|
case "go":
|
|
130
|
-
return
|
|
332
|
+
return null;
|
|
131
333
|
}
|
|
132
334
|
}
|