claudeup 6.5.0 → 6.6.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.
Files changed (82) 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__/catalog-notice.test.ts +3 -3
  9. package/src/__tests__/cli-apply-seams.test.ts +2 -0
  10. package/src/__tests__/cli-tool-commands.test.ts +1 -1
  11. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  12. package/src/__tests__/cli-update-view.test.ts +142 -2
  13. package/src/__tests__/content-drift.test.ts +60 -0
  14. package/src/__tests__/enabled-not-installed.test.ts +1 -2
  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__/marketplace-badge.test.ts +1 -1
  34. package/src/__tests__/marketplaces.test.ts +1 -0
  35. package/src/__tests__/plugin-cli-argv.test.ts +1 -1
  36. package/src/__tests__/plugin-row-text.test.ts +336 -0
  37. package/src/__tests__/scope-action.test.ts +69 -19
  38. package/src/__tests__/shell-script-callers.test.ts +8 -8
  39. package/src/__tests__/tool-probe.test.ts +1400 -0
  40. package/src/__tests__/update-apply.test.ts +283 -1
  41. package/src/__tests__/update-plan.test.ts +773 -31
  42. package/src/__tests__/version-unknown-state.test.ts +388 -0
  43. package/src/cli/router.ts +0 -5
  44. package/src/cli/update-view.ts +92 -12
  45. package/src/cli/update.ts +114 -38
  46. package/src/data/marketplaces.ts +13 -0
  47. package/src/services/active-closure.ts +117 -0
  48. package/src/services/catalog-cache-store.ts +6 -2
  49. package/src/services/claude-settings.ts +1 -173
  50. package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
  51. package/src/services/gitignore-fixer.ts +1 -1
  52. package/src/services/marketplace-refresh.ts +30 -0
  53. package/src/services/plugin-manager.ts +200 -283
  54. package/src/services/resolver.ts +7 -2
  55. package/src/services/tool-probe.ts +1637 -0
  56. package/src/services/toolchain.ts +97 -0
  57. package/src/services/update-engine.ts +220 -32
  58. package/src/services/update-plan.ts +640 -160
  59. package/src/services/version-snapshot.ts +1 -1
  60. package/src/types/bun.d.ts +6 -0
  61. package/src/ui/App.tsx +2 -27
  62. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  63. package/src/ui/adapters/pluginsAdapter.ts +58 -1
  64. package/src/ui/components/modals/ModalContainer.tsx +0 -26
  65. package/src/ui/hooks/index.ts +0 -1
  66. package/src/ui/hooks/useGitignoreModal.ts +2 -3
  67. package/src/ui/renderers/cliToolRenderers.tsx +108 -67
  68. package/src/ui/renderers/pluginRenderers.tsx +157 -98
  69. package/src/ui/renderers/pluginRowText.ts +70 -0
  70. package/src/ui/renderers/pluginStatusText.ts +56 -0
  71. package/src/ui/screens/CliToolsScreen.tsx +154 -280
  72. package/src/ui/screens/PluginsScreen.tsx +156 -140
  73. package/src/ui/state/types.ts +0 -7
  74. package/src/__tests__/dual-write-prevention.test.ts +0 -380
  75. package/src/__tests__/gap-fill-versions.test.ts +0 -389
  76. package/src/__tests__/plugin-version-check.test.ts +0 -762
  77. package/src/cli/claude.ts +0 -21
  78. package/src/prerunner/index.ts +0 -500
  79. package/src/services/plugin-version-check.ts +0 -360
  80. package/src/services/update-cache.ts +0 -78
  81. package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
  82. package/src/ui/hooks/useMismatchModal.ts +0 -89
@@ -4,6 +4,7 @@ import {
4
4
  defaultMarketplaces,
5
5
  getAllMarketplaces,
6
6
  } from "../../data/marketplaces.js";
7
+ import { loadActiveClosure } from "../../services/active-closure.js";
7
8
  import {
8
9
  type PluginScope,
9
10
  addMarketplace as cliAddMarketplace,
@@ -37,11 +38,6 @@ import {
37
38
  getPluginSetupFromSource,
38
39
  installPluginDeps,
39
40
  } from "../../services/plugin-setup.js";
40
- import {
41
- type VersionMismatchInfo,
42
- checkPluginVersionMismatches,
43
- checkSinglePluginMismatch,
44
- } from "../../services/plugin-version-check.js";
45
41
  import { saveProfile } from "../../services/profiles.js";
46
42
  import {
47
43
  fetchPluginContents,
@@ -57,7 +53,15 @@ import {
57
53
  pluginNeedsWork,
58
54
  updatePluginInScope,
59
55
  } from "../../services/update-engine.js";
60
- import { planPluginUpdates } from "../../services/update-plan.js";
56
+ // The ONE plugin classifier, shared with `claudeup update`. Every state this
57
+ // screen shows or acts on — row text, detail panel, header counts, U / a, the
58
+ // scope picker — reads the same map it builds.
59
+ import {
60
+ type PluginUpdateItem,
61
+ classifyPluginRows,
62
+ countPluginStates,
63
+ scopeIsBehind,
64
+ } from "../../services/update-plan.js";
61
65
  import {
62
66
  diffVersions,
63
67
  loadSeenVersions,
@@ -70,13 +74,13 @@ import {
70
74
  type PluginComponentItem,
71
75
  type PluginPluginItem,
72
76
  buildPluginBrowserItems,
77
+ noMoveReasons,
73
78
  pluginContentsRepo,
74
79
  } from "../adapters/pluginsAdapter.js";
75
80
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
76
81
  import { ScrollableList } from "../components/ScrollableList.js";
77
82
  import { ScreenLayout } from "../components/layout/index.js";
78
83
  import { useKeyboard } from "../hooks/useKeyboard.js";
79
- import { useMismatchModal } from "../hooks/useMismatchModal.js";
80
84
  import {
81
85
  renderPluginDetail,
82
86
  renderPluginRow,
@@ -101,7 +105,6 @@ export function PluginsScreen() {
101
105
  const modal = useModal();
102
106
  const progress = useProgress();
103
107
  const dimensions = useDimensions();
104
- const mismatchModal = useMismatchModal();
105
108
 
106
109
  const isSearchActive =
107
110
  state.isSearching &&
@@ -122,13 +125,32 @@ export function PluginsScreen() {
122
125
  /** Re-render tick so the retry countdown in the notice actually counts down. */
123
126
  const [nowTick, setNowTick] = useState(() => Date.now());
124
127
 
128
+ /**
129
+ * The active profile's pins — the same values `claudeup update` plans with.
130
+ *
131
+ * `undefined` until the first fetch answers; `null` when no profile could be
132
+ * resolved (no manifest, an invalid one, or several with none active). In
133
+ * that case every row is judged against "latest" and the notice says so.
134
+ */
135
+ const [pins, setPins] = useState<
136
+ Readonly<Record<string, string>> | null | undefined
137
+ >(undefined);
138
+
125
139
  // Fetch data (always fetches all scopes)
126
140
  const fetchData = useCallback(async () => {
127
141
  dispatch({ type: "PLUGINS_DATA_LOADING" });
128
142
  try {
129
143
  const localMarketplaces = await getLocalMarketplacesInfo();
130
144
  const allMarketplaces = getAllMarketplaces(localMarketplaces);
131
- const pluginData = await getAvailablePlugins(state.projectPath);
145
+ // Read-only, never throws. `resolveBins: false`: this screen needs only
146
+ // the plugin pins, not every declared plugin's binary requirements.
147
+ const [pluginData, active] = await Promise.all([
148
+ getAvailablePlugins(state.projectPath),
149
+ loadActiveClosure(state.projectPath || process.cwd(), {
150
+ resolveBins: false,
151
+ }),
152
+ ]);
153
+ setPins(active?.closure.plugins ?? null);
132
154
 
133
155
  // Which catalogs answered, and which only appeared to. Read straight
134
156
  // after the fetch, while the record still describes this attempt.
@@ -147,7 +169,7 @@ export function PluginsScreen() {
147
169
  );
148
170
 
149
171
  // Surface version changes made outside claudeup (Claude Code's own
150
- // plugin update, the prerunner, a manual CLI call). Diffing against
172
+ // plugin update, a manual CLI call). Diffing against
151
173
  // what we last rendered catches all of them; logging only what
152
174
  // claudeup did would miss every one.
153
175
  const current: Record<string, string> = {};
@@ -234,6 +256,18 @@ export function PluginsScreen() {
234
256
  const isReloading =
235
257
  pluginsState.plugins.status === "loading" && shownPlugins !== undefined;
236
258
 
259
+ /**
260
+ * One status per plugin that has a pin, computed ONCE per data/pins change.
261
+ * The rows, the detail panel, the header counts, U / a and the scope picker
262
+ * all read this map, so none of them can disagree with another — or with
263
+ * `claudeup update`, which runs the same classifier over the same pins.
264
+ */
265
+ const statuses = useMemo(
266
+ (): Map<string, PluginUpdateItem> =>
267
+ shownPlugins ? classifyPluginRows(shownPlugins, pins ?? {}) : new Map(),
268
+ [shownPlugins, pins],
269
+ );
270
+
237
271
  // Build list items (categories + plugins)
238
272
  const allItems = useMemo((): PluginBrowserItem[] => {
239
273
  if (!shownMarketplaces || !shownPlugins) return [];
@@ -243,6 +277,7 @@ export function PluginsScreen() {
243
277
  collapsedMarketplaces: pluginsState.collapsedMarketplaces,
244
278
  expandedPlugins: pluginsState.expandedPlugins,
245
279
  pluginContents: pluginsState.pluginContents,
280
+ statuses,
246
281
  });
247
282
  }, [
248
283
  shownMarketplaces,
@@ -250,6 +285,7 @@ export function PluginsScreen() {
250
285
  pluginsState.collapsedMarketplaces,
251
286
  pluginsState.expandedPlugins,
252
287
  pluginsState.pluginContents,
288
+ statuses,
253
289
  ]);
254
290
 
255
291
  // Filter items by search query
@@ -517,32 +553,11 @@ export function PluginsScreen() {
517
553
  else if (event.name === "l") handleScopeToggle("local");
518
554
  else if (event.name === "d") handleRemoveDeprecated();
519
555
  else if (event.name === "a") handleUpdateAll();
520
- else if (event.name === "m") handleCheckMismatches();
521
556
  else if (event.name === "s") handleSaveAsProfile();
522
557
  });
523
558
 
524
559
  // ── Helpers ───────────────────────────────────────────────────────────────
525
560
 
526
- /**
527
- * Check for version mismatch after a plugin update and show the
528
- * interactive mismatch modal with a "Fix all projects" button.
529
- *
530
- * The [0] index bug in Claude Code means the update may not actually
531
- * take effect if another project's entry is at index 0. The shared
532
- * useMismatchModal hook provides the same fix flow available at startup.
533
- */
534
- const warnIfVersionMismatch = async (pluginId: string): Promise<void> => {
535
- try {
536
- const projectPath = state.projectPath || process.cwd();
537
- const mismatch = await checkSinglePluginMismatch(pluginId, projectPath);
538
- if (mismatch) {
539
- mismatchModal.show([mismatch]);
540
- }
541
- } catch {
542
- // Non-fatal: mismatch check is best-effort
543
- }
544
- };
545
-
546
561
  // ── Action handlers ────────────────────────────────────────────────────────
547
562
 
548
563
  const handleRefresh = async () => {
@@ -851,24 +866,24 @@ export function PluginsScreen() {
851
866
  }
852
867
  } else if (item.kind === "plugin") {
853
868
  const plugin = item.plugin;
854
- const latestVersion = plugin.version || "0.0.0";
869
+ const status = statuses.get(plugin.id);
855
870
 
871
+ // The label's arrow and what the key then does are ONE rule —
872
+ // `scopeIsBehind` — so the picker can never show no arrow and then
873
+ // update, or show an arrow and then uninstall.
856
874
  const buildScopeLabel = (
857
875
  name: string,
876
+ scopeKey: PluginScope,
858
877
  scope: ScopeStatus | undefined,
859
878
  desc: string,
860
879
  ) => {
861
880
  const installed = isInstalledInScope(scope);
862
881
  const ver = scope?.version;
863
- const hasUpdate =
864
- ver &&
865
- latestVersion &&
866
- ver !== latestVersion &&
867
- latestVersion !== "0.0.0";
868
882
  let label = installed ? `● ${name}` : `○ ${name}`;
869
883
  label += ` (${desc})`;
870
884
  if (ver) label += ` v${ver}`;
871
- if (hasUpdate) label += ` → v${latestVersion}`;
885
+ if (scopeIsBehind(status, scopeKey) && status?.target)
886
+ label += ` → v${status.target}`;
872
887
  // Enabled in settings with nothing installed. Without saying so the
873
888
  // row reads exactly like a scope that was never enabled, and the
874
889
  // user cannot tell that picking it repairs rather than adds.
@@ -878,15 +893,25 @@ export function PluginsScreen() {
878
893
 
879
894
  const scopeOptions = [
880
895
  {
881
- label: buildScopeLabel("User", plugin.userScope, "global"),
896
+ label: buildScopeLabel("User", "user", plugin.userScope, "global"),
882
897
  value: "user",
883
898
  },
884
899
  {
885
- label: buildScopeLabel("Project", plugin.projectScope, "team"),
900
+ label: buildScopeLabel(
901
+ "Project",
902
+ "project",
903
+ plugin.projectScope,
904
+ "team",
905
+ ),
886
906
  value: "project",
887
907
  },
888
908
  {
889
- label: buildScopeLabel("Local", plugin.localScope, "private"),
909
+ label: buildScopeLabel(
910
+ "Local",
911
+ "local",
912
+ plugin.localScope,
913
+ "private",
914
+ ),
890
915
  value: "local",
891
916
  },
892
917
  ];
@@ -912,8 +937,12 @@ export function PluginsScreen() {
912
937
  : "Local";
913
938
 
914
939
  // Decided from the registry-backed version, not the `enabledPlugins`
915
- // flag alone — see resolveScopeAction.
916
- const action = resolveScopeAction(selectedScope, latestVersion);
940
+ // flag alone — see resolveScopeAction. Update-versus-uninstall is the
941
+ // same `scopeIsBehind` the label above used.
942
+ const action = resolveScopeAction(
943
+ selectedScope,
944
+ scopeIsBehind(status, scopeValue as PluginScope),
945
+ );
917
946
 
918
947
  try {
919
948
  const scope = scopeValue as PluginScope;
@@ -928,7 +957,7 @@ export function PluginsScreen() {
928
957
  );
929
958
  // Scope is the user's explicit choice here, so this does NOT go
930
959
  // through applyPlugins (which advances every scope behind). It
931
- // still records the version READ BACK, not `latestVersion`.
960
+ // still records the version READ BACK, not the catalog version.
932
961
  await updatePluginInScope(
933
962
  plugin.id,
934
963
  scope,
@@ -939,7 +968,7 @@ export function PluginsScreen() {
939
968
  `Installing ${plugin.name} to ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
940
969
  );
941
970
  // Same read-back as the update branch above. A fresh install is
942
- // no more able to guarantee it got `latestVersion` than an
971
+ // no more able to guarantee it got the catalog version than an
943
972
  // update is — the marketplace serves whatever it publishes at
944
973
  // the moment of the call — so recording the version we asked
945
974
  // for made `install --check` compare that number against a copy
@@ -956,9 +985,6 @@ export function PluginsScreen() {
956
985
  if (action !== "install") {
957
986
  modal.hideModal();
958
987
  }
959
- if (action === "update") {
960
- await warnIfVersionMismatch(plugin.id);
961
- }
962
988
  fetchData();
963
989
  } catch (error) {
964
990
  modal.hideModal();
@@ -970,7 +996,9 @@ export function PluginsScreen() {
970
996
  const handleUpdate = async () => {
971
997
  const item = selectableItems[pluginsState.selectedIndex];
972
998
  if (!item || item.kind !== "plugin") return;
973
- if (!item.plugin.hasUpdate && !item.plugin.contentStale) return;
999
+ // The row said "update" or "stale — reinstall"; nothing else is work.
1000
+ const status = statuses.get(item.plugin.id);
1001
+ if (status?.action !== "update" && status?.action !== "repair") return;
974
1002
 
975
1003
  const plugin = item.plugin;
976
1004
  const scope: PluginScope =
@@ -979,28 +1007,26 @@ export function PluginsScreen() {
979
1007
  // No version bump to install over — the files changed underneath an
980
1008
  // unchanged version, and `plugin install` no-ops on "already installed".
981
1009
  // Only uninstall+install re-copies. See claude-cli.ts repairPlugin.
982
- const repairing = !plugin.hasUpdate && plugin.contentStale;
1010
+ const repairing = status.action === "repair";
983
1011
 
984
1012
  modal.loading(
985
1013
  repairing
986
1014
  ? `Repairing ${plugin.name}…\nclaude plugin uninstall ${plugin.id} --scope ${scope} && claude plugin install ${plugin.id} --scope ${scope}`
987
1015
  : `Updating ${plugin.name}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
988
1016
  );
1017
+ const reasons = noMoveReasons();
989
1018
  try {
990
1019
  // One plugin, but the SAME engine — so a single update records the
991
1020
  // version that landed and touches every scope that is behind, exactly
992
- // as `claudeup update` does. `repairing` is not re-derived here: the
993
- // planner already encodes it as the `repair` action.
994
- const catalog = new Map(
995
- pluginsState.plugins.status === "success"
996
- ? pluginsState.plugins.data.map((p) => [p.id, p] as const)
997
- : [[plugin.id, plugin] as const],
998
- );
999
- const items = planPluginUpdates({ [plugin.id]: "latest" }, catalog);
1021
+ // as `claudeup update` does. The plan item IS the row's status: the
1022
+ // classifier's answer for this plugin under its real pin (the
1023
+ // profile's, or "latest" for an undeclared install — see pinForRow),
1024
+ // so what runs is exactly what the row claimed.
1000
1025
  const result = await applyPlugins(
1001
- items,
1026
+ [status],
1002
1027
  state.projectPath || process.cwd(),
1003
1028
  REAL_PLUGIN_DEPS,
1029
+ reasons.reporter,
1004
1030
  );
1005
1031
  modal.hideModal();
1006
1032
  if (result.failed.length > 0) {
@@ -1011,7 +1037,16 @@ export function PluginsScreen() {
1011
1037
  );
1012
1038
  return;
1013
1039
  }
1014
- await warnIfVersionMismatch(plugin.id);
1040
+ // Ran, exited 0, moved nothing — and the row will not have moved
1041
+ // either. Saying nothing here is what made "press U, press U,
1042
+ // forever": the CLI names the cause, so the screen does too.
1043
+ if (result.unchanged.length > 0) {
1044
+ await modal.message(
1045
+ "No change",
1046
+ reasons.lines(result.unchanged),
1047
+ "info",
1048
+ );
1049
+ }
1015
1050
  fetchData();
1016
1051
  } catch (error) {
1017
1052
  modal.hideModal();
@@ -1034,29 +1069,26 @@ export function PluginsScreen() {
1034
1069
  const handleUpdateAll = async () => {
1035
1070
  if (pluginsState.plugins.status !== "success") return;
1036
1071
 
1037
- // Content-stale plugins are included: they need repairing, and excluding
1038
- // them meant "update all" left the machine with silently outdated files.
1039
- const updatable = pluginsState.plugins.data.filter(
1040
- (p) => p.hasUpdate || p.contentStale,
1041
- );
1042
- if (updatable.length === 0) return;
1072
+ // Exactly the rows that say "update" or "stale — reinstall", each planned
1073
+ // under its real pin. Content-stale plugins are included: they need
1074
+ // repairing, and excluding them meant "update all" left the machine with
1075
+ // silently outdated files.
1076
+ const items = [...statuses.values()]
1077
+ .filter((s) => s.action === "update" || s.action === "repair")
1078
+ .sort((a, b) => a.pluginId.localeCompare(b.pluginId));
1079
+ if (items.length === 0) return;
1043
1080
 
1044
1081
  const projectPath = state.projectPath || process.cwd();
1045
- // The screen's own list IS the catalog the planner needs.
1046
- const catalog = new Map(pluginsState.plugins.data.map((p) => [p.id, p]));
1047
- const items = planPluginUpdates(
1048
- Object.fromEntries(updatable.map((p) => [p.id, "latest"])),
1049
- catalog,
1050
- );
1051
1082
  const total = items.filter(pluginNeedsWork).length;
1052
1083
 
1053
1084
  let done = 0;
1085
+ const reasons = noMoveReasons();
1054
1086
  const reporter: ApplyReporter = {
1055
1087
  begin: (name) => {
1056
1088
  done++;
1057
1089
  modal.loading(`Updating ${name} (${done}/${total})…`);
1058
1090
  },
1059
- finish: () => {},
1091
+ finish: reasons.reporter.finish,
1060
1092
  detach: (work) => work(),
1061
1093
  };
1062
1094
 
@@ -1067,10 +1099,6 @@ export function PluginsScreen() {
1067
1099
  REAL_PLUGIN_DEPS,
1068
1100
  reporter,
1069
1101
  );
1070
- const updatedPluginIds = items
1071
- .filter(pluginNeedsWork)
1072
- .map((i) => i.pluginId)
1073
- .filter((id) => !result.failed.includes(id));
1074
1102
  modal.hideModal();
1075
1103
  if (result.failed.length > 0) {
1076
1104
  await modal.message(
@@ -1079,18 +1107,12 @@ export function PluginsScreen() {
1079
1107
  "error",
1080
1108
  );
1081
1109
  }
1082
- // Batch mismatch checks into a single modal
1083
- const allMismatches: VersionMismatchInfo[] = [];
1084
- for (const pluginId of updatedPluginIds) {
1085
- try {
1086
- const m = await checkSinglePluginMismatch(pluginId, projectPath);
1087
- if (m) allMismatches.push(m);
1088
- } catch {
1089
- // best-effort
1090
- }
1091
- }
1092
- if (allMismatches.length > 0) {
1093
- mismatchModal.show(allMismatches);
1110
+ if (result.unchanged.length > 0) {
1111
+ await modal.message(
1112
+ "No change",
1113
+ reasons.lines(result.unchanged),
1114
+ "info",
1115
+ );
1094
1116
  }
1095
1117
  fetchData();
1096
1118
  } catch (error) {
@@ -1099,39 +1121,11 @@ export function PluginsScreen() {
1099
1121
  }
1100
1122
  };
1101
1123
 
1102
- /**
1103
- * Manual on-demand check for plugin version mismatches across projects.
1104
- * Bound to the `m` keybind. Shows a success message if everything is
1105
- * aligned, or the interactive fix modal if any mismatches are found.
1106
- */
1107
- const handleCheckMismatches = async () => {
1108
- try {
1109
- const projectPath = state.projectPath || process.cwd();
1110
- const mismatches = await checkPluginVersionMismatches(projectPath);
1111
- if (mismatches.length === 0) {
1112
- await modal.message(
1113
- "No Mismatches",
1114
- "All enabled plugins in this project will load their expected versions. No action needed.",
1115
- "success",
1116
- );
1117
- return;
1118
- }
1119
- mismatchModal.show(mismatches);
1120
- } catch (error) {
1121
- await modal.message(
1122
- "Error",
1123
- `Failed to check mismatches: ${error}`,
1124
- "error",
1125
- );
1126
- }
1127
- };
1128
-
1129
1124
  const handleScopeToggle = async (scope: "user" | "project" | "local") => {
1130
1125
  const item = selectableItems[pluginsState.selectedIndex];
1131
1126
  if (!item || item.kind !== "plugin") return;
1132
1127
 
1133
1128
  const plugin = item.plugin;
1134
- const latestVersion = plugin.version || "0.0.0";
1135
1129
  const scopeLabel =
1136
1130
  scope === "user" ? "User" : scope === "project" ? "Project" : "Local";
1137
1131
 
@@ -1145,8 +1139,13 @@ export function PluginsScreen() {
1145
1139
  // This used to key off `scopeData.enabled` alone, so a plugin the list
1146
1140
  // had just labelled "not installed" answered its own scope key by
1147
1141
  // uninstalling itself — the `enabledPlugins` flag was set, the registry
1148
- // entry was missing, and only the flag was consulted.
1149
- const action = resolveScopeAction(scopeData, latestVersion);
1142
+ // entry was missing, and only the flag was consulted. Update-versus-
1143
+ // uninstall is the classifier's `scopeIsBehind`, the same rule the Enter
1144
+ // scope picker labels with.
1145
+ const action = resolveScopeAction(
1146
+ scopeData,
1147
+ scopeIsBehind(statuses.get(plugin.id), scope),
1148
+ );
1150
1149
 
1151
1150
  try {
1152
1151
  if (action === "uninstall") {
@@ -1181,9 +1180,6 @@ export function PluginsScreen() {
1181
1180
  if (action !== "install") {
1182
1181
  modal.hideModal();
1183
1182
  }
1184
- if (action === "update") {
1185
- await warnIfVersionMismatch(plugin.id);
1186
- }
1187
1183
  fetchData();
1188
1184
  } catch (error) {
1189
1185
  modal.hideModal();
@@ -1369,33 +1365,53 @@ export function PluginsScreen() {
1369
1365
  { keys: ["u", "/", "p", "/", "l"], label: "toggle" },
1370
1366
  { keys: ["U"], label: "update" },
1371
1367
  { keys: ["a"], label: "all" },
1372
- { keys: ["m"], label: "mismatches" },
1373
1368
  { keys: ["s"], label: "profile" },
1374
1369
  { keys: ["/"], label: "search" },
1375
1370
  ];
1376
1371
 
1377
1372
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
1378
1373
  const plugins: PluginInfo[] = shownPlugins ?? [];
1379
- const installedCount = plugins.filter((p) => p.enabled).length;
1380
- const updateCount = plugins.filter(
1381
- (p) =>
1382
- (p.hasUpdate || p.contentStale) &&
1383
- (p.userScope?.enabled ||
1384
- p.projectScope?.enabled ||
1385
- p.localScope?.enabled),
1386
- ).length;
1387
- const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}`;
1388
- const searchPlaceholder = `${scopeLabel} ${installedCount} installed${updateCount > 0 ? ` ${updateCount} ⬆` : ""} │ / to search`;
1389
-
1374
+ // "installed" must mean the same thing here as in the rows beside it. This
1375
+ // counted `p.enabled` — the settings flag — so the header read "11 installed"
1376
+ // on a screen whose every row said "not installed". Same predicate as the
1377
+ // row and the detail panel now.
1378
+ const isInstalledAnywhere = (p: PluginInfo) =>
1379
+ isInstalledInScope(p.userScope) ||
1380
+ isInstalledInScope(p.projectScope) ||
1381
+ isInstalledInScope(p.localScope);
1382
+ const installedCount = plugins.filter(isInstalledAnywhere).length;
1383
+ // Every state count is the classifier's, over the same map the rows read,
1384
+ // so a header can never count a row the list beside it does not show.
1385
+ const counts = countPluginStates(statuses.values());
1386
+ const updateCount = counts.update + counts.repair;
1387
+ // Cannot tell whether it is current. Counted and shown separately: folding
1388
+ // it into `updateCount` would overstate, and folding it into silence is the
1389
+ // "cannot tell rendered as fine" bug.
1390
+ const unknownCount = counts.unknown;
1391
+ const installCount = counts.install;
1392
+ const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}${installCount > 0 ? ` │ ${installCount} to install` : ""}${unknownCount > 0 ? ` │ ${unknownCount} unverified` : ""}`;
1393
+ const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""}${installCount > 0 ? ` │ ${installCount} +` : ""}${unknownCount > 0 ? ` │ ${unknownCount} ?` : ""} │ / to search`;
1394
+
1395
+ const catalogNotice = buildCatalogNotice({
1396
+ failures: catalogFailures,
1397
+ unverifiedPlugins: [...statuses.values()].filter(
1398
+ (s) => s.reason === "catalog-unverified",
1399
+ ).length,
1400
+ width: dimensions.terminalWidth,
1401
+ now: nowTick,
1402
+ });
1403
+ // Without a resolved profile, exact pins are unknown here and every row is
1404
+ // judged against "latest". Saying so is what keeps a row that disagrees with
1405
+ // `claudeup update <profile>` from reading as a contradiction.
1390
1406
  const notice = isReloading ? (
1391
1407
  <text fg={theme.colors.muted}>⟳ Refreshing catalogs from GitHub…</text>
1392
1408
  ) : (
1393
- buildCatalogNotice({
1394
- failures: catalogFailures,
1395
- unverifiedPlugins: plugins.filter((p) => p.updateCheckFailed).length,
1396
- width: dimensions.terminalWidth,
1397
- now: nowTick,
1398
- })
1409
+ (catalogNotice ??
1410
+ (pins === null ? (
1411
+ <text fg={theme.colors.muted}>
1412
+ profile not resolved — states judged against latest
1413
+ </text>
1414
+ ) : null))
1399
1415
  );
1400
1416
 
1401
1417
  return (
@@ -1,6 +1,5 @@
1
1
  import type { ModelsConfig, ModelsStatus } from "../../services/models-core.js";
2
2
  import type { PluginInfo } from "../../services/plugin-manager.js";
3
- import type { VersionMismatchInfo } from "../../services/plugin-version-check.js";
4
3
  import type {
5
4
  PluginComponent,
6
5
  PluginSkillDetail,
@@ -113,12 +112,6 @@ export type ModalState =
113
112
  | {
114
113
  type: "loading";
115
114
  message: string;
116
- }
117
- | {
118
- type: "version-mismatch";
119
- mismatches: VersionMismatchInfo[];
120
- onFix: () => void;
121
- onDismiss: () => void;
122
115
  };
123
116
 
124
117
  // ============================================================================