claudeup 6.0.0 → 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/src/cli/update.ts CHANGED
@@ -34,13 +34,7 @@
34
34
  * `cli/update-view.ts` owns every string. Nothing in this file formats.
35
35
  */
36
36
 
37
- import path from "node:path";
38
- import type { PluginScope } from "../services/claude-cli.js";
39
- import {
40
- isClaudeAvailable,
41
- repairPlugin,
42
- updatePlugin,
43
- } from "../services/claude-cli.js";
37
+ import { isClaudeAvailable } from "../services/claude-cli.js";
44
38
  import { clearContentDriftCache } from "../services/content-drift.js";
45
39
  import { checkBinaries } from "../services/doctor-bins.js";
46
40
  import { validateManifest } from "../services/manifest.js";
@@ -50,16 +44,18 @@ import {
50
44
  type PluginInfo,
51
45
  clearMarketplaceCache,
52
46
  getAvailablePlugins,
53
- readInstalledVersionForScope,
54
- saveInstalledPluginVersionForScope,
55
47
  } from "../services/plugin-manager.js";
56
48
  import { resolveProfile } from "../services/resolver.js";
57
- import { installSkill } from "../services/skills-manager.js";
58
49
  import { getInstalledSkillNames } from "../services/skills-manager.js";
59
50
  import { activeProfile } from "../services/symlink-manager.js";
60
- import { binInstallCommand, binUpgradeCommand } from "../services/toolchain.js";
51
+ // The apply half lives in services/ so the TUI runs the SAME code — see the
52
+ // header of update-engine.ts for what drifted while it did not.
53
+ import {
54
+ type ApplyReporter,
55
+ applyPlan,
56
+ planWorkItems,
57
+ } from "../services/update-engine.js";
61
58
  import {
62
- type PluginUpdateItem,
63
59
  type UpdatePlan,
64
60
  planBinUpdates,
65
61
  planHasWork,
@@ -68,12 +64,10 @@ import {
68
64
  planSkillUpdates,
69
65
  summarizePlan,
70
66
  } from "../services/update-plan.js";
71
- import type { ProfileSkillRef, SkillInfo } from "../types/index.js";
72
67
  import { brand } from "../ui/theme.js";
73
68
  import { bold, dim, fg, width } from "./ansi.js";
74
69
  import { ensureManifest } from "./bootstrap.js";
75
70
  import { LiveRegion, withPaused } from "./live.js";
76
- import { runShell } from "./prompt.js";
77
71
  import {
78
72
  type ApplyState,
79
73
  type Step,
@@ -114,253 +108,6 @@ export function parseArgs(args: string[]): UpdateFlags {
114
108
  };
115
109
  }
116
110
 
117
- // -- apply -------------------------------------------------------------------
118
-
119
- /** Map a manifest skill ref to the SkillInfo shape installSkill expects. */
120
- function toSkillInfo(ref: ProfileSkillRef): SkillInfo {
121
- return {
122
- id: `${ref.repo}/${ref.path}`,
123
- name: ref.name,
124
- source: {
125
- label: ref.repo,
126
- repo: ref.repo,
127
- skillsPath: path.dirname(ref.path),
128
- },
129
- repoPath: ref.path,
130
- gitBlobSha: "",
131
- frontmatter: null,
132
- installed: false,
133
- installedScope: null,
134
- hasUpdate: false,
135
- };
136
- }
137
-
138
- interface ApplyResult {
139
- ok: number;
140
- failed: string[];
141
- }
142
-
143
- /** An exact pin the installer could not deliver, discovered by reading back. */
144
- export interface PinMismatch {
145
- pluginId: string;
146
- pinned: string;
147
- actual: string | null;
148
- scope: PluginScope;
149
- }
150
-
151
- export interface PluginApplyResult extends ApplyResult {
152
- mismatches: PinMismatch[];
153
- }
154
-
155
- /**
156
- * How the apply loop reports itself.
157
- *
158
- * Injected rather than hard-coded, because the loop is the same work whether it
159
- * is driving an animated meter, printing plain lines into a pipe, or running
160
- * under a test that wants silence. The default prints one line per item, which
161
- * is what every non-interactive caller wants.
162
- */
163
- export interface ApplyReporter {
164
- /** An item is starting. Called before any of its work. */
165
- begin(name: string): void;
166
- /** An item finished. `detail` is already formatted for display. */
167
- finish(ok: boolean, name: string, detail: string, ms: number): void;
168
- /**
169
- * Run `work` with the terminal released.
170
- *
171
- * A binary upgrade spawns `brew`/`npm` with inherited stdio: it writes and
172
- * moves the cursor itself, so any animated block must be erased first or the
173
- * two interleave into garbage.
174
- */
175
- detach<T>(work: () => Promise<T>): Promise<T>;
176
- }
177
-
178
- const PLAIN_REPORTER: ApplyReporter = {
179
- begin: () => {},
180
- finish: (ok, name, detail) => {
181
- const line = `${ok ? "✓" : "⚠"} ${name}${detail ? ` ${detail}` : ""}`;
182
- if (ok) console.log(line);
183
- else console.warn(line);
184
- },
185
- detach: (work) => work(),
186
- };
187
-
188
- /**
189
- * The four side-effecting calls the plugin apply path makes, injectable so the
190
- * path itself can be tested.
191
- *
192
- * This exists because the apply half shipped with no coverage while the pure
193
- * planner had forty tests — and every real defect found in review lived here,
194
- * not there. Following the same shape `checkBinaries` and `resolveProfile`
195
- * already use for their impure edges.
196
- */
197
- export interface PluginApplyDeps {
198
- update: (pluginId: string, scope: PluginScope) => Promise<void>;
199
- repair: (
200
- pluginId: string,
201
- scope: PluginScope,
202
- projectPath: string,
203
- ) => Promise<void>;
204
- readInstalled: (
205
- pluginId: string,
206
- scope: PluginScope,
207
- projectPath: string,
208
- ) => Promise<string | null>;
209
- saveInstalled: (
210
- pluginId: string,
211
- version: string,
212
- scope: PluginScope,
213
- projectPath: string,
214
- ) => Promise<void>;
215
- }
216
-
217
- const REAL_PLUGIN_DEPS: PluginApplyDeps = {
218
- update: updatePlugin,
219
- repair: repairPlugin,
220
- readInstalled: readInstalledVersionForScope,
221
- saveInstalled: saveInstalledPluginVersionForScope,
222
- };
223
-
224
- /**
225
- * Scopes the apply loop would touch for `item`. Empty means it is skipped.
226
- *
227
- * A plugin the profile declares but nothing has installed goes in at PROJECT
228
- * scope, matching `install`: a profile is a property of one repo, so its
229
- * plugins must not be enabled machine-wide.
230
- */
231
- function scopesToApply(item: PluginUpdateItem): PluginScope[] {
232
- if (item.action === "current" || item.action === "unknown") return [];
233
- return item.action === "install" ? ["project"] : item.scopes;
234
- }
235
-
236
- /**
237
- * Whether the apply loop will do anything for `item`.
238
- *
239
- * Exported shape for ONE reason: the progress meter's denominator must be the
240
- * same predicate the loop actually applies. It was previously re-derived at the
241
- * call site and replicated only the first of the two skips — an actionable item
242
- * with empty `scopes` was counted but never run, so the bar would stop short of
243
- * full and sit there looking hung at the end of a successful update.
244
- */
245
- export function pluginNeedsWork(item: PluginUpdateItem): boolean {
246
- return scopesToApply(item).length > 0;
247
- }
248
-
249
- export async function applyPlugins(
250
- items: PluginUpdateItem[],
251
- projectPath: string,
252
- deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
253
- report: ApplyReporter = PLAIN_REPORTER,
254
- ): Promise<PluginApplyResult> {
255
- const result: PluginApplyResult = { ok: 0, failed: [], mismatches: [] };
256
-
257
- for (const item of items) {
258
- const scopes = scopesToApply(item);
259
- if (scopes.length === 0) continue;
260
-
261
- report.begin(item.pluginId);
262
- const startedAt = Date.now();
263
-
264
- try {
265
- for (const scope of scopes) {
266
- if (item.action === "repair") {
267
- await deps.repair(item.pluginId, scope, projectPath);
268
- } else {
269
- await deps.update(item.pluginId, scope);
270
- }
271
-
272
- // Record what LANDED, not what we asked for.
273
- //
274
- // `claude plugin install` accepts no version, so an exact pin is a
275
- // request the installer cannot honour — it fetches whatever the
276
- // marketplace publishes now. Writing `item.target` here recorded the
277
- // pin as satisfied whatever actually installed, and `install --check`
278
- // reads that same field to decide whether the machine has drifted.
279
- // The gate would have compared the pin against a copy of itself and
280
- // reported clean forever.
281
- const actual = await deps.readInstalled(
282
- item.pluginId,
283
- scope,
284
- projectPath,
285
- );
286
- if (actual) {
287
- await deps.saveInstalled(item.pluginId, actual, scope, projectPath);
288
- }
289
- if (item.pinned !== "latest" && actual !== item.pinned) {
290
- result.mismatches.push({
291
- pluginId: item.pluginId,
292
- pinned: item.pinned,
293
- actual,
294
- scope,
295
- });
296
- }
297
- }
298
- report.finish(
299
- true,
300
- item.pluginId,
301
- `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`,
302
- Date.now() - startedAt,
303
- );
304
- result.ok++;
305
- } catch (error) {
306
- const msg = error instanceof Error ? error.message : String(error);
307
- report.finish(false, item.pluginId, msg, Date.now() - startedAt);
308
- result.failed.push(item.pluginId);
309
- }
310
- }
311
-
312
- return result;
313
- }
314
-
315
- async function applyBins(
316
- plan: UpdatePlan,
317
- report: ApplyReporter = PLAIN_REPORTER,
318
- ): Promise<ApplyResult> {
319
- const result: ApplyResult = { ok: 0, failed: [] };
320
- for (const item of plan.bins) {
321
- if (item.action === "current") continue;
322
- const bin = plan.binSpecs.get(item.name);
323
- if (!bin) continue;
324
- const cmd =
325
- item.action === "install"
326
- ? binInstallCommand(bin)
327
- : binUpgradeCommand(bin);
328
- report.begin(item.name);
329
- const startedAt = Date.now();
330
- // The installer owns the terminal for the duration: it streams its own
331
- // output through inherited stdio.
332
- const succeeded = await report.detach(async () => {
333
- console.log(`\n$ ${cmd}`);
334
- return runShell(cmd);
335
- });
336
- report.finish(succeeded, item.name, cmd, Date.now() - startedAt);
337
- if (succeeded) result.ok++;
338
- else result.failed.push(item.name);
339
- }
340
- return result;
341
- }
342
-
343
- async function applySkills(
344
- plan: UpdatePlan,
345
- projectPath: string,
346
- report: ApplyReporter = PLAIN_REPORTER,
347
- ): Promise<ApplyResult> {
348
- const result: ApplyResult = { ok: 0, failed: [] };
349
- for (const item of plan.skills) {
350
- report.begin(item.name);
351
- const startedAt = Date.now();
352
- try {
353
- await installSkill(toSkillInfo(item.ref), "project", projectPath);
354
- report.finish(true, item.name, item.ref.repo, Date.now() - startedAt);
355
- result.ok++;
356
- } catch (error) {
357
- const msg = error instanceof Error ? error.message : String(error);
358
- report.finish(false, item.name, msg, Date.now() - startedAt);
359
- result.failed.push(item.name);
360
- }
361
- }
362
- return result;
363
- }
364
111
 
365
112
  // -- phase runner ------------------------------------------------------------
366
113
 
@@ -637,14 +384,10 @@ export async function runUpdateCommand(
637
384
  return 1;
638
385
  }
639
386
 
640
- // Exactly the items the three apply loops will touch. `pluginNeedsWork` is
641
- // the SAME predicate applyPlugins uses re-deriving it is how a meter
642
- // drifts from the loop it measures.
643
- const workItems = [
644
- ...plan.plugins.filter(pluginNeedsWork).map((p) => p.pluginId),
645
- ...plan.bins.filter((b) => b.action !== "current").map((b) => b.name),
646
- ...plan.skills.map((s) => s.name),
647
- ];
387
+ // Exactly the items the engine will touch. Derived by the engine from the
388
+ // same predicates its loops use, so the meter's denominator cannot drift
389
+ // from the work a bar stuck short of full reads as a hang.
390
+ const workItems = planWorkItems(plan);
648
391
 
649
392
  if (!planHasWork(plan) || workItems.length === 0) {
650
393
  for (const line of summaryLine(
@@ -741,26 +484,14 @@ export async function runUpdateCommand(
741
484
  console.log("");
742
485
  applyLive.start((tick) => applyFrame(state, tick, Date.now()));
743
486
 
744
- const pluginResult = await applyPlugins(
745
- plan.plugins,
746
- projectPath,
747
- REAL_PLUGIN_DEPS,
748
- reporter,
749
- );
750
- const binResult = await applyBins(plan, reporter);
751
- const skillResult = await applySkills(plan, projectPath, reporter);
487
+ const outcome = await applyPlan(plan, projectPath, { report: reporter });
752
488
 
753
489
  applyLive.stop();
754
490
 
755
- const failed = [
756
- ...pluginResult.failed,
757
- ...binResult.failed,
758
- ...skillResult.failed,
759
- ];
760
- const changed = pluginResult.ok + binResult.ok + skillResult.ok;
491
+ const { failed } = outcome;
761
492
 
762
493
  for (const line of summaryLine(
763
- changed,
494
+ outcome.changed,
764
495
  counts.current,
765
496
  counts.unknown,
766
497
  failed.length,
@@ -773,13 +504,13 @@ export async function runUpdateCommand(
773
504
  // of the manifest rather than something this run did wrong, and failing here
774
505
  // would make `claudeup update` exit 1 forever for any profile with a pin.
775
506
  // `install --check` with `strictVersions` is where pin drift is GATED.
776
- if (pluginResult.mismatches.length > 0) {
507
+ if (outcome.plugins.mismatches.length > 0) {
777
508
  console.log(
778
509
  warnLine(
779
- `${bold(String(pluginResult.mismatches.length))} pinned version(s) are not what is installed. Only the newest published version can be installed:`,
510
+ `${bold(String(outcome.plugins.mismatches.length))} pinned version(s) are not what is installed. Only the newest published version can be installed:`,
780
511
  ),
781
512
  );
782
- for (const m of pluginResult.mismatches) {
513
+ for (const m of outcome.plugins.mismatches) {
783
514
  console.log(
784
515
  noteLine(
785
516
  `${m.pluginId} (${m.scope}): manifest pins ${m.pinned}, installed ${m.actual ?? "unknown"}`,
@@ -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
- packageManager: "bun" | "npm" | "pip" | "brew" | "go";
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
- /** 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
  }