claudeup 6.5.1 → 6.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/active-closure.test.ts +184 -0
  3. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  4. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  5. package/src/__tests__/blackbox-parity.test.ts +684 -0
  6. package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
  7. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  8. package/src/__tests__/cli-apply-seams.test.ts +2 -0
  9. package/src/__tests__/cli-tool-commands.test.ts +1 -1
  10. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  11. package/src/__tests__/cli-update-view.test.ts +142 -2
  12. package/src/__tests__/content-drift.test.ts +60 -0
  13. package/src/__tests__/conventions-manager.test.ts +45 -45
  14. package/src/__tests__/enabled-not-installed.test.ts +2 -3
  15. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  16. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  17. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  18. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  19. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  20. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  21. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  22. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  23. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  24. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  25. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  26. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  27. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  28. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  29. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  30. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  31. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  32. package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
  33. package/src/__tests__/models-presets-marketplace.test.ts +141 -0
  34. package/src/__tests__/plugin-cli-argv.test.ts +1 -1
  35. package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
  36. package/src/__tests__/plugin-row-text.test.ts +336 -0
  37. package/src/__tests__/resolver.test.ts +3 -3
  38. package/src/__tests__/scope-action.test.ts +69 -19
  39. package/src/__tests__/shell-script-callers.test.ts +8 -8
  40. package/src/__tests__/tool-probe.test.ts +1400 -0
  41. package/src/__tests__/update-apply.test.ts +283 -1
  42. package/src/__tests__/update-plan.test.ts +773 -31
  43. package/src/__tests__/version-unknown-state.test.ts +388 -0
  44. package/src/cli/router.ts +0 -5
  45. package/src/cli/update-view.ts +92 -12
  46. package/src/cli/update.ts +114 -38
  47. package/src/data/models-presets.ts +10 -11
  48. package/src/data/predefined-profiles.ts +7 -7
  49. package/src/services/active-closure.ts +117 -0
  50. package/src/services/catalog-cache-store.ts +6 -2
  51. package/src/services/claude-settings.ts +2 -174
  52. package/src/services/community-styles.ts +1 -1
  53. package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
  54. package/src/services/gitignore-fixer.ts +1 -1
  55. package/src/services/marketplace-refresh.ts +30 -0
  56. package/src/services/plugin-manager.ts +200 -283
  57. package/src/services/resolver.ts +7 -2
  58. package/src/services/tool-probe.ts +1637 -0
  59. package/src/services/toolchain.ts +97 -0
  60. package/src/services/update-engine.ts +220 -32
  61. package/src/services/update-plan.ts +640 -160
  62. package/src/services/version-snapshot.ts +1 -1
  63. package/src/types/bun.d.ts +6 -0
  64. package/src/ui/App.tsx +2 -27
  65. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  66. package/src/ui/adapters/pluginsAdapter.ts +57 -0
  67. package/src/ui/components/modals/ModalContainer.tsx +0 -26
  68. package/src/ui/hooks/index.ts +0 -1
  69. package/src/ui/hooks/useGitignoreModal.ts +2 -3
  70. package/src/ui/renderers/cliToolRenderers.tsx +108 -67
  71. package/src/ui/renderers/pluginRenderers.tsx +157 -98
  72. package/src/ui/renderers/pluginRowText.ts +70 -0
  73. package/src/ui/renderers/pluginStatusText.ts +56 -0
  74. package/src/ui/screens/CliToolsScreen.tsx +154 -280
  75. package/src/ui/screens/PluginsScreen.tsx +156 -140
  76. package/src/ui/state/types.ts +0 -7
  77. package/src/__tests__/dual-write-prevention.test.ts +0 -380
  78. package/src/__tests__/gap-fill-versions.test.ts +0 -389
  79. package/src/__tests__/plugin-version-check.test.ts +0 -762
  80. package/src/cli/claude.ts +0 -21
  81. package/src/prerunner/index.ts +0 -500
  82. package/src/services/plugin-version-check.ts +0 -360
  83. package/src/services/update-cache.ts +0 -78
  84. package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
  85. package/src/ui/hooks/useMismatchModal.ts +0 -89
@@ -58,6 +58,15 @@ export interface PythonCliInstaller {
58
58
  * frequently is not an executable at all.
59
59
  */
60
60
  uninstall(pkg: string): Command;
61
+ /**
62
+ * Move an ALREADY-INSTALLED package to exactly `version`.
63
+ *
64
+ * Distinct from `install(pkg, version)`, which each of these treats as a
65
+ * no-op once the package is present — `uv tool install pkg==v` answers
66
+ * "already installed" when any version is there. `--force` is what makes
67
+ * the pin land.
68
+ */
69
+ pin(pkg: string, version: string): Command;
61
70
  }
62
71
 
63
72
  export const PYTHON_CLI_INSTALLERS: readonly PythonCliInstaller[] = [
@@ -81,6 +90,10 @@ export const PYTHON_CLI_INSTALLERS: readonly PythonCliInstaller[] = [
81
90
  args: ["tool", "install", "--force", "--upgrade", pkg],
82
91
  }),
83
92
  uninstall: (pkg) => ({ cmd: "uv", args: ["tool", "uninstall", pkg] }),
93
+ pin: (pkg, version) => ({
94
+ cmd: "uv",
95
+ args: ["tool", "install", "--force", `${pkg}==${version}`],
96
+ }),
84
97
  },
85
98
  {
86
99
  probe: "pipx",
@@ -92,6 +105,10 @@ export const PYTHON_CLI_INSTALLERS: readonly PythonCliInstaller[] = [
92
105
  // manage the package.
93
106
  upgrade: (pkg) => ({ cmd: "pipx", args: ["install", "--force", pkg] }),
94
107
  uninstall: (pkg) => ({ cmd: "pipx", args: ["uninstall", pkg] }),
108
+ pin: (pkg, version) => ({
109
+ cmd: "pipx",
110
+ args: ["install", "--force", `${pkg}==${version}`],
111
+ }),
95
112
  },
96
113
  {
97
114
  probe: "python3",
@@ -114,6 +131,10 @@ export const PYTHON_CLI_INSTALLERS: readonly PythonCliInstaller[] = [
114
131
  cmd: "python3",
115
132
  args: ["-m", "pip", "uninstall", "-y", pkg],
116
133
  }),
134
+ pin: (pkg, version) => ({
135
+ cmd: "python3",
136
+ args: ["-m", "pip", "install", "--user", `${pkg}==${version}`],
137
+ }),
117
138
  },
118
139
  ];
119
140
 
@@ -295,6 +316,82 @@ export function binUpgradeCommand(
295
316
  }
296
317
  }
297
318
 
319
+ /**
320
+ * The command that moves an ALREADY-INSTALLED binary to exactly its pin.
321
+ *
322
+ * `claudeup update` plans a pinned tool that sits below its pin as an upgrade
323
+ * TO the pin, and neither of the other two builders delivers that:
324
+ * {@link binUpgradeCommand} goes to the newest version, past the pin, and a
325
+ * plain `uv tool install pkg==v` is a no-op once any version is installed.
326
+ *
327
+ * bun, npm and go take the version in the same token `binInstallCommand` builds
328
+ * (`pkg@v`), and each replaces an installed version with it. pip goes through
329
+ * the resolved installer's own `pin`. brew installs only its newest version, so
330
+ * there is nothing to return — the planner never asks for it (a brew pin below
331
+ * the installed version is reported, not planned).
332
+ *
333
+ * Null also when the requirement carries no pin.
334
+ */
335
+ export function binPinCommand(
336
+ bin: {
337
+ name: string;
338
+ via: BinInstaller;
339
+ package?: string;
340
+ formula?: string;
341
+ module?: string;
342
+ version?: string;
343
+ },
344
+ python: PythonCliInstaller = resolvePythonCliInstaller(),
345
+ ): Command | null {
346
+ if (!bin.version) return null;
347
+ switch (bin.via) {
348
+ case "bun":
349
+ case "npm":
350
+ case "go":
351
+ return binInstallCommand(bin, python);
352
+ case "pip":
353
+ return python.pin(bin.package ?? bin.name, bin.version);
354
+ case "brew":
355
+ return null;
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Which of the three builders a requirement in a given state needs.
361
+ *
362
+ * THE one rule that turns a state into a command, so a front end cannot show
363
+ * one target and run another. It was written twice before: `binCommandFor`
364
+ * (`update-engine.ts`) for `claudeup update`, and the CLI Tools screen's row,
365
+ * which always built `@latest` — so a row reading `→ v9.0.0` (the pin) ran
366
+ * `bun install -g pkg@latest` and left the tool permanently ahead of its pin,
367
+ * a state claudeup cannot leave. Both now come through here.
368
+ *
369
+ * `install` → {@link binInstallCommand} (which appends the pin, when there is
370
+ * one). An upgrade whose target IS the pin → {@link binPinCommand}, because
371
+ * {@link binUpgradeCommand} would overshoot it. Any other upgrade → the newest
372
+ * version.
373
+ *
374
+ * Null only when no sound command exists: a pinned brew formula, where brew
375
+ * installs its newest version and nothing else (the pin is advisory).
376
+ */
377
+ export function binCommandForState(
378
+ state: "install" | "upgrade",
379
+ target: string | null,
380
+ bin: {
381
+ name: string;
382
+ via: BinInstaller;
383
+ package?: string;
384
+ formula?: string;
385
+ module?: string;
386
+ version?: string;
387
+ },
388
+ python: PythonCliInstaller = resolvePythonCliInstaller(),
389
+ ): Command | null {
390
+ if (state === "install") return binInstallCommand(bin, python);
391
+ if (bin.version && target === bin.version) return binPinCommand(bin, python);
392
+ return binUpgradeCommand(bin, python);
393
+ }
394
+
298
395
  /**
299
396
  * The command that REMOVES an installed binary.
300
397
  *
@@ -30,7 +30,11 @@
30
30
  */
31
31
 
32
32
  import path from "node:path";
33
- import type { ProfileSkillRef, SkillInfo } from "../types/index.js";
33
+ import type {
34
+ ProfileSkillRef,
35
+ ResolvedBin,
36
+ SkillInfo,
37
+ } from "../types/index.js";
34
38
  import type { Command } from "../utils/command-utils.js";
35
39
  import { formatCommand } from "../utils/command-utils.js";
36
40
  import { runCommand } from "../utils/run.js";
@@ -41,14 +45,34 @@ import {
41
45
  saveInstalledPluginVersionForScope,
42
46
  } from "./plugin-manager.js";
43
47
  import { installSkill } from "./skills-manager.js";
44
- import { binInstallCommand, binUpgradeCommand } from "./toolchain.js";
45
- import type { PluginUpdateItem, UpdatePlan } from "./update-plan.js";
48
+ import {
49
+ type ToolFacts,
50
+ compareToolVersions,
51
+ probeTool,
52
+ } from "./tool-probe.js";
53
+ import { binCommandForState } from "./toolchain.js";
54
+ import {
55
+ type BinUpdateItem,
56
+ type PluginUpdateItem,
57
+ type UpdatePlan,
58
+ binNeedsWork,
59
+ } from "./update-plan.js";
46
60
 
47
61
  // -- results -----------------------------------------------------------------
48
62
 
49
63
  export interface ApplyResult {
64
+ /** Items that CHANGED — the installer exited 0 and read-back shows movement. */
50
65
  ok: number;
51
66
  failed: string[];
67
+ /**
68
+ * Items whose installer exited 0 but whose read-back shows nothing moved.
69
+ *
70
+ * Not a failure, and not a change. A stale tap, a PATH that still resolves
71
+ * an older copy, a marketplace clone behind its catalog — each "succeeds",
72
+ * lands on the same version, and is planned identically next run. Counting
73
+ * those as changed is how "3 changed" was printed on every run forever.
74
+ */
75
+ unchanged: string[];
52
76
  }
53
77
 
54
78
  /** An exact pin the installer could not deliver, discovered by reading back. */
@@ -70,6 +94,8 @@ export interface UpdateOutcome {
70
94
  skills: ApplyResult;
71
95
  /** Items that changed, across all three. */
72
96
  changed: number;
97
+ /** Names whose installer exited 0 but read-back shows no movement. */
98
+ unchanged: string[];
73
99
  /** Names that failed, across all three. */
74
100
  failed: string[];
75
101
  }
@@ -158,10 +184,9 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
158
184
  * scope, matching `install`: a profile is a property of one repo, so its
159
185
  * plugins must not be enabled machine-wide.
160
186
  *
161
- * Every scope holding an outdated install is returned, not just one. The
162
- * prerunner learned this the expensive way: it detected drift, "fixed" it at
163
- * user scope and reported success, leaving the project row untouched so the
164
- * same drift reappeared on every run.
187
+ * Every scope holding an outdated install is returned, not just one. Fixing a
188
+ * single hardcoded scope does not converge: it reports success while the
189
+ * project row stays untouched, so the same drift reappears on the next run.
165
190
  */
166
191
  function scopesToApply(item: PluginUpdateItem): PluginScope[] {
167
192
  if (item.action === "current" || item.action === "unknown") return [];
@@ -219,13 +244,51 @@ async function advanceInScope(
219
244
  else await deps.install(pluginId, scope);
220
245
  }
221
246
 
247
+ /**
248
+ * Why nothing moved for `item`, judged by READ-BACK; null when something did.
249
+ *
250
+ * No installer output is parsed — "already at the latest version" is
251
+ * locale- and version-fragile. The registry row the apply loop already reads
252
+ * back is compared with the before-version the plan carries:
253
+ * - update: every touched scope reads back exactly what it held before;
254
+ * - install: no touched scope reads back any version at all;
255
+ * - repair: never — same version is its definition; the files moved.
256
+ */
257
+ function pluginNoMove(
258
+ item: PluginUpdateItem,
259
+ landed: ReadonlyArray<[PluginScope, string | null]>,
260
+ ): string | null {
261
+ if (landed.length === 0) return null;
262
+ if (item.action === "update") {
263
+ const same = landed.every(
264
+ ([scope, actual]) => (actual ?? undefined) === item.beforeVersions[scope],
265
+ );
266
+ if (!same) return null;
267
+ const v = landed.find(([, a]) => a)?.[1];
268
+ return v
269
+ ? `the installer exited 0 but ${item.pluginId} is still ${v}`
270
+ : `the installer exited 0 but no version of ${item.pluginId} was recorded`;
271
+ }
272
+ if (item.action === "install") {
273
+ return landed.every(([, actual]) => actual === null)
274
+ ? `the installer exited 0 but no version of ${item.pluginId} was recorded`
275
+ : null;
276
+ }
277
+ return null;
278
+ }
279
+
222
280
  export async function applyPlugins(
223
281
  items: PluginUpdateItem[],
224
282
  projectPath: string,
225
283
  deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
226
284
  report: ApplyReporter = SILENT_REPORTER,
227
285
  ): Promise<PluginApplyResult> {
228
- const result: PluginApplyResult = { ok: 0, failed: [], mismatches: [] };
286
+ const result: PluginApplyResult = {
287
+ ok: 0,
288
+ failed: [],
289
+ unchanged: [],
290
+ mismatches: [],
291
+ };
229
292
 
230
293
  for (const item of items) {
231
294
  const scopes = scopesToApply(item);
@@ -233,6 +296,7 @@ export async function applyPlugins(
233
296
 
234
297
  report.begin(item.pluginId);
235
298
  const startedAt = Date.now();
299
+ const landed: Array<[PluginScope, string | null]> = [];
236
300
 
237
301
  try {
238
302
  for (const scope of scopes) {
@@ -268,6 +332,7 @@ export async function applyPlugins(
268
332
  scope,
269
333
  projectPath,
270
334
  );
335
+ landed.push([scope, actual]);
271
336
  if (actual) {
272
337
  await deps.saveInstalled(item.pluginId, actual, scope, projectPath);
273
338
  }
@@ -280,13 +345,24 @@ export async function applyPlugins(
280
345
  });
281
346
  }
282
347
  }
283
- report.finish(
284
- true,
285
- item.pluginId,
286
- `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`,
287
- Date.now() - startedAt,
288
- );
289
- result.ok++;
348
+ const noMove = pluginNoMove(item, landed);
349
+ if (noMove) {
350
+ report.finish(
351
+ true,
352
+ item.pluginId,
353
+ `no change — ${noMove}`,
354
+ Date.now() - startedAt,
355
+ );
356
+ result.unchanged.push(item.pluginId);
357
+ } else {
358
+ report.finish(
359
+ true,
360
+ item.pluginId,
361
+ `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`,
362
+ Date.now() - startedAt,
363
+ );
364
+ result.ok++;
365
+ }
290
366
  } catch (error) {
291
367
  const msg = error instanceof Error ? error.message : String(error);
292
368
  report.finish(false, item.pluginId, msg, Date.now() - startedAt);
@@ -369,6 +445,74 @@ export async function updatePluginInScope(
369
445
 
370
446
  // -- binaries ----------------------------------------------------------------
371
447
 
448
+ /**
449
+ * The command a bin item runs, or null when it is not work.
450
+ *
451
+ * The state→command rule itself is {@link binCommandForState}, shared with the
452
+ * TUI's CLI Tools row so the two front ends cannot run different commands for
453
+ * the same row. The CLI's plan report renders this same function, so what it
454
+ * shows is what runs.
455
+ */
456
+ export function binCommandFor(
457
+ item: BinUpdateItem,
458
+ spec: ResolvedBin,
459
+ ): Command | null {
460
+ if (!binNeedsWork(item)) return null;
461
+ return binCommandForState(
462
+ item.action === "install" ? "install" : "upgrade",
463
+ item.target,
464
+ spec,
465
+ );
466
+ }
467
+
468
+ /**
469
+ * Why nothing moved for a bin, judged by RE-PROBING it; null when it moved.
470
+ *
471
+ * The re-probe resolves PATH again, so it reads the binary that runs NOW —
472
+ * which is what catches the no-ops planning cannot see: a stale brew tap, an
473
+ * installer that wrote a copy PATH does not resolve, `~/.bun/bin` missing from
474
+ * PATH. Versions are compared with the same comparator the planner used,
475
+ * because before and after can come from different sources ("v0.13.10" from
476
+ * `uv tool list` against "0.13.10").
477
+ *
478
+ * A read-back that cannot read a version is NOT a no-op: failing to read is not
479
+ * evidence nothing changed, and saying so would turn every go-less machine's
480
+ * upgrade into a warning.
481
+ */
482
+ function binNoMove(item: BinUpdateItem, after: ToolFacts): string | null {
483
+ if (item.action === "install" && !after.present) {
484
+ return `the installer exited 0 but ${item.name} is still not on PATH`;
485
+ }
486
+ if (after.managed === false) {
487
+ return `installed, but PATH still runs ${after.resolvedPath ?? "another copy"}`;
488
+ }
489
+ if (
490
+ item.action === "upgrade" &&
491
+ after.installed !== null &&
492
+ item.installed !== null &&
493
+ compareToolVersions(after.installed, item.installed) === 0
494
+ ) {
495
+ return `the installer exited 0 but ${item.name} is still ${after.installed}`;
496
+ }
497
+ return null;
498
+ }
499
+
500
+ /**
501
+ * The read-back probe, and the ONLY caller that lets the probe run a tool's
502
+ * `check` command.
503
+ *
504
+ * Planning never does: `check` comes from a marketplace `plugin.json`, and
505
+ * `--dry-run` / `--check` must execute nothing a catalogue supplied. Here the
506
+ * installer for this very tool has just run with the user's consent, and the
507
+ * probe's own narrowing means the only argv that can run is the tool asking
508
+ * itself its version (`tool-probe.ts`, `versionFromCheck`). Without it a tool
509
+ * whose installer keeps no readable record reads back as "version unknown",
510
+ * which R6 counts as changed — the safe answer, but never "no change — still
511
+ * 3.7c", the one the user needs.
512
+ */
513
+ const REAL_READ_BACK = (spec: ResolvedBin): Promise<ToolFacts> =>
514
+ probeTool(spec, undefined, { allowCheckCommand: true });
515
+
372
516
  export async function applyBins(
373
517
  plan: UpdatePlan,
374
518
  report: ApplyReporter = SILENT_REPORTER,
@@ -377,30 +521,69 @@ export async function applyBins(
377
521
  // as an invitation to hand it a string. It takes argv, and nothing here
378
522
  // builds a shell line.
379
523
  run: (command: Command) => Promise<boolean> = runCommand,
524
+ /** Read-back seam: re-probe ONE tool after its installer ran. */
525
+ probe: (spec: ResolvedBin) => Promise<ToolFacts> = REAL_READ_BACK,
380
526
  ): Promise<ApplyResult> {
381
- const result: ApplyResult = { ok: 0, failed: [] };
527
+ const result: ApplyResult = { ok: 0, failed: [], unchanged: [] };
382
528
  for (const item of plan.bins) {
383
- if (item.action === "current") continue;
529
+ // The SAME predicate planWorkItems and planHasWork use. Skipping only
530
+ // `current` here would apply an `unknown` — "cannot tell" run as an
531
+ // upgrade.
532
+ if (!binNeedsWork(item)) continue;
384
533
  const bin = plan.binSpecs.get(item.name);
385
534
  if (!bin) continue;
386
- const cmd =
387
- item.action === "install"
388
- ? binInstallCommand(bin)
389
- : binUpgradeCommand(bin);
390
535
  report.begin(item.name);
391
536
  const startedAt = Date.now();
537
+ const cmd = binCommandFor(item, bin);
538
+ if (!cmd) {
539
+ // Unreachable from the planner (a brew pin is never planned as work),
540
+ // but the meter counted this item, so it must finish.
541
+ report.finish(
542
+ false,
543
+ item.name,
544
+ `no command can move ${bin.via} to ${item.target ?? "the pin"}`,
545
+ Date.now() - startedAt,
546
+ );
547
+ result.failed.push(item.name);
548
+ continue;
549
+ }
392
550
  // The installer owns the terminal for the duration: it streams its own
393
551
  // output through inherited stdio.
394
552
  const succeeded = await report.detach(() => run(cmd));
553
+ if (!succeeded) {
554
+ report.finish(
555
+ false,
556
+ item.name,
557
+ formatCommand(cmd),
558
+ Date.now() - startedAt,
559
+ );
560
+ result.failed.push(item.name);
561
+ continue;
562
+ }
563
+ let noMove: string | null = null;
564
+ try {
565
+ noMove = binNoMove(item, await probe(bin));
566
+ } catch {
567
+ // Could not read back: not evidence of a no-op.
568
+ }
395
569
  // The reporter renders; only the argv above is executed.
396
- report.finish(
397
- succeeded,
398
- item.name,
399
- formatCommand(cmd),
400
- Date.now() - startedAt,
401
- );
402
- if (succeeded) result.ok++;
403
- else result.failed.push(item.name);
570
+ if (noMove) {
571
+ report.finish(
572
+ true,
573
+ item.name,
574
+ `no change — ${noMove}`,
575
+ Date.now() - startedAt,
576
+ );
577
+ result.unchanged.push(item.name);
578
+ } else {
579
+ report.finish(
580
+ true,
581
+ item.name,
582
+ formatCommand(cmd),
583
+ Date.now() - startedAt,
584
+ );
585
+ result.ok++;
586
+ }
404
587
  }
405
588
  return result;
406
589
  }
@@ -432,7 +615,7 @@ export async function applySkills(
432
615
  report: ApplyReporter = SILENT_REPORTER,
433
616
  install: typeof installSkill = installSkill,
434
617
  ): Promise<ApplyResult> {
435
- const result: ApplyResult = { ok: 0, failed: [] };
618
+ const result: ApplyResult = { ok: 0, failed: [], unchanged: [] };
436
619
  for (const item of plan.skills) {
437
620
  report.begin(item.name);
438
621
  const startedAt = Date.now();
@@ -456,6 +639,8 @@ export interface ApplyPlanOptions {
456
639
  report?: ApplyReporter;
457
640
  /** Installer execution seam — see the note on {@link applyBins}. */
458
641
  run?: (command: Command) => Promise<boolean>;
642
+ /** Read-back seam — see {@link applyBins}. Default: `probeTool(spec)`. */
643
+ probe?: (spec: ResolvedBin) => Promise<ToolFacts>;
459
644
  }
460
645
 
461
646
  /**
@@ -478,14 +663,17 @@ export async function applyPlan(
478
663
  opts.deps ?? REAL_PLUGIN_DEPS,
479
664
  report,
480
665
  );
481
- const bins = await applyBins(plan, report, opts.run);
666
+ const bins = await applyBins(plan, report, opts.run, opts.probe);
482
667
  const skills = await applySkills(plan, projectPath, report);
483
668
 
484
669
  return {
485
670
  plugins,
486
671
  bins,
487
672
  skills,
673
+ // `ok` counts only items read-back shows moved, so a no-op installer
674
+ // run is never "changed".
488
675
  changed: plugins.ok + bins.ok + skills.ok,
676
+ unchanged: [...plugins.unchanged, ...bins.unchanged, ...skills.unchanged],
489
677
  failed: [...plugins.failed, ...bins.failed, ...skills.failed],
490
678
  };
491
679
  }
@@ -497,7 +685,7 @@ export async function applyPlan(
497
685
  export function planWorkItems(plan: UpdatePlan): string[] {
498
686
  return [
499
687
  ...plan.plugins.filter(pluginNeedsWork).map((p) => p.pluginId),
500
- ...plan.bins.filter((b) => b.action !== "current").map((b) => b.name),
688
+ ...plan.bins.filter(binNeedsWork).map((b) => b.name),
501
689
  ...plan.skills.map((s) => s.name),
502
690
  ];
503
691
  }