claudeup 4.11.2 → 4.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.11.2",
3
+ "version": "4.13.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
package/src/ui/App.js CHANGED
@@ -8,7 +8,8 @@ import { ModalContainer } from "./components/modals/index.js";
8
8
  import { PluginsScreen, McpScreen, McpRegistryScreen, SettingsScreen, CliToolsScreen, ModelSelectorScreen, ProfilesScreen, SkillsScreen, } from "./screens/index.js";
9
9
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
10
10
  import { migrateMarketplaceRename, recoverMarketplaceSettings, } from "../services/claude-settings.js";
11
- import { checkPluginVersionMismatches, fixAllPluginVersionMismatches, formatMismatchModal, } from "../services/plugin-version-check.js";
11
+ import { checkPluginVersionMismatches, } from "../services/plugin-version-check.js";
12
+ import { useMismatchModal } from "./hooks/useMismatchModal.js";
12
13
  import { checkForUpdates, getCurrentVersion, } from "../services/version-check.js";
13
14
  import { useKeyboardHandler } from "./hooks/useKeyboardHandler.js";
14
15
  import { ProgressBar } from "./components/layout/ProgressBar.js";
@@ -189,6 +190,7 @@ function AppContent({ onExit }) {
189
190
  const [updateInfo, setUpdateInfo] = useState(null);
190
191
  const [recoveryReport, setRecoveryReport] = useState(null);
191
192
  const [mismatchData, setMismatchData] = useState(null);
193
+ const mismatchModal = useMismatchModal();
192
194
  // Check for updates on startup (non-blocking)
193
195
  useEffect(() => {
194
196
  checkForUpdates()
@@ -206,68 +208,9 @@ function AppContent({ onExit }) {
206
208
  useEffect(() => {
207
209
  if (!mismatchData || mismatchData.length === 0)
208
210
  return;
209
- dispatch({
210
- type: "SHOW_MODAL",
211
- modal: {
212
- type: "select",
213
- title: "⚠ Plugin Version Mismatch",
214
- message: formatMismatchModal(mismatchData),
215
- options: [
216
- {
217
- label: "Fix all projects",
218
- value: "fix",
219
- description: "Align all projects to this project's versions",
220
- },
221
- {
222
- label: "Dismiss",
223
- value: "dismiss",
224
- description: "Ignore for now",
225
- },
226
- ],
227
- onSelect: async (value) => {
228
- dispatch({ type: "HIDE_MODAL" });
229
- if (value === "fix") {
230
- dispatch({
231
- type: "SHOW_PROGRESS",
232
- state: { message: "Fixing plugin versions..." },
233
- });
234
- try {
235
- await fixAllPluginVersionMismatches(mismatchData);
236
- dispatch({ type: "HIDE_PROGRESS" });
237
- dispatch({
238
- type: "SHOW_MODAL",
239
- modal: {
240
- type: "message",
241
- title: "Fixed",
242
- message: "All plugin versions aligned. Restart Claude Code for changes to take effect.",
243
- variant: "success",
244
- onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
245
- },
246
- });
247
- }
248
- catch {
249
- dispatch({ type: "HIDE_PROGRESS" });
250
- dispatch({
251
- type: "SHOW_MODAL",
252
- modal: {
253
- type: "message",
254
- title: "Error",
255
- message: "Failed to fix plugin versions. Try running claudeup again.",
256
- variant: "error",
257
- onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
258
- },
259
- });
260
- }
261
- }
262
- setMismatchData(null);
263
- },
264
- onCancel: () => {
265
- dispatch({ type: "HIDE_MODAL" });
266
- setMismatchData(null);
267
- },
268
- },
269
- });
270
- }, [mismatchData, dispatch]);
211
+ mismatchModal.show(mismatchData);
212
+ setMismatchData(null);
213
+ }, [mismatchData, mismatchModal]);
271
214
  // Auto-refresh marketplaces on startup
272
215
  useEffect(() => {
273
216
  const noRefresh = process.argv.includes("--no-refresh");
package/src/ui/App.tsx CHANGED
@@ -30,10 +30,9 @@ import {
30
30
  } from "../services/claude-settings.js";
31
31
  import {
32
32
  checkPluginVersionMismatches,
33
- fixAllPluginVersionMismatches,
34
- formatMismatchModal,
35
33
  type VersionMismatchInfo,
36
34
  } from "../services/plugin-version-check.js";
35
+ import { useMismatchModal } from "./hooks/useMismatchModal.js";
37
36
  import {
38
37
  checkForUpdates,
39
38
  getCurrentVersion,
@@ -317,6 +316,7 @@ function AppContent({ onExit }: AppContentProps) {
317
316
  const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
318
317
  const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
319
318
  const [mismatchData, setMismatchData] = useState<VersionMismatchInfo[] | null>(null);
319
+ const mismatchModal = useMismatchModal();
320
320
 
321
321
  // Check for updates on startup (non-blocking)
322
322
  useEffect(() => {
@@ -335,68 +335,9 @@ function AppContent({ onExit }: AppContentProps) {
335
335
  // Show mismatch modal when data arrives
336
336
  useEffect(() => {
337
337
  if (!mismatchData || mismatchData.length === 0) return;
338
- dispatch({
339
- type: "SHOW_MODAL",
340
- modal: {
341
- type: "select",
342
- title: "⚠ Plugin Version Mismatch",
343
- message: formatMismatchModal(mismatchData),
344
- options: [
345
- {
346
- label: "Fix all projects",
347
- value: "fix",
348
- description: "Align all projects to this project's versions",
349
- },
350
- {
351
- label: "Dismiss",
352
- value: "dismiss",
353
- description: "Ignore for now",
354
- },
355
- ],
356
- onSelect: async (value: string) => {
357
- dispatch({ type: "HIDE_MODAL" });
358
- if (value === "fix") {
359
- dispatch({
360
- type: "SHOW_PROGRESS",
361
- state: { message: "Fixing plugin versions..." },
362
- });
363
- try {
364
- await fixAllPluginVersionMismatches(mismatchData);
365
- dispatch({ type: "HIDE_PROGRESS" });
366
- dispatch({
367
- type: "SHOW_MODAL",
368
- modal: {
369
- type: "message",
370
- title: "Fixed",
371
- message:
372
- "All plugin versions aligned. Restart Claude Code for changes to take effect.",
373
- variant: "success",
374
- onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
375
- },
376
- });
377
- } catch {
378
- dispatch({ type: "HIDE_PROGRESS" });
379
- dispatch({
380
- type: "SHOW_MODAL",
381
- modal: {
382
- type: "message",
383
- title: "Error",
384
- message: "Failed to fix plugin versions. Try running claudeup again.",
385
- variant: "error",
386
- onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
387
- },
388
- });
389
- }
390
- }
391
- setMismatchData(null);
392
- },
393
- onCancel: () => {
394
- dispatch({ type: "HIDE_MODAL" });
395
- setMismatchData(null);
396
- },
397
- },
398
- });
399
- }, [mismatchData, dispatch]);
338
+ mismatchModal.show(mismatchData);
339
+ setMismatchData(null);
340
+ }, [mismatchData, mismatchModal]);
400
341
 
401
342
  // Auto-refresh marketplaces on startup
402
343
  useEffect(() => {
@@ -7,6 +7,7 @@ import { InputModal } from "./InputModal.js";
7
7
  import { SelectModal } from "./SelectModal.js";
8
8
  import { MessageModal } from "./MessageModal.js";
9
9
  import { LoadingModal } from "./LoadingModal.js";
10
+ import { VersionMismatchModal } from "./VersionMismatchModal.js";
10
11
  /**
11
12
  * Container that renders the active modal as an overlay
12
13
  * Handles ALL keyboard events when a modal is open to avoid
@@ -27,6 +28,9 @@ export function ModalContainer() {
27
28
  if (modal?.type === "select") {
28
29
  setSelectIndex(modal.defaultIndex ?? 0);
29
30
  }
31
+ else if (modal?.type === "version-mismatch") {
32
+ setSelectIndex(0);
33
+ }
30
34
  }
31
35
  // Handle keyboard events for modals
32
36
  useKeyboard((key) => {
@@ -44,6 +48,8 @@ export function ModalContainer() {
44
48
  modal.onCancel();
45
49
  else if (modal.type === "message")
46
50
  modal.onDismiss();
51
+ else if (modal.type === "version-mismatch")
52
+ modal.onDismiss();
47
53
  return;
48
54
  }
49
55
  // 'q' to close — but NOT for input modals (need to type 'q')
@@ -54,6 +60,8 @@ export function ModalContainer() {
54
60
  modal.onCancel();
55
61
  else if (modal.type === "message")
56
62
  modal.onDismiss();
63
+ else if (modal.type === "version-mismatch")
64
+ modal.onDismiss();
57
65
  return;
58
66
  }
59
67
  // Input modal — let OpenTUI <input> handle Enter via onSubmit
@@ -73,6 +81,22 @@ export function ModalContainer() {
73
81
  }
74
82
  return;
75
83
  }
84
+ // Version-mismatch modal — handle navigation and selection (2 options)
85
+ if (modal.type === "version-mismatch") {
86
+ if (key.name === "return" || key.name === "enter") {
87
+ if (selectIndex === 0)
88
+ modal.onFix();
89
+ else
90
+ modal.onDismiss();
91
+ }
92
+ else if (key.name === "up" || key.name === "k") {
93
+ setSelectIndex((prev) => Math.max(0, prev - 1));
94
+ }
95
+ else if (key.name === "down" || key.name === "j") {
96
+ setSelectIndex((prev) => Math.min(1, prev + 1));
97
+ }
98
+ return;
99
+ }
76
100
  // Message modal — Enter to dismiss
77
101
  if (modal.type === "message") {
78
102
  if (key.name === "return" || key.name === "enter") {
@@ -101,6 +125,8 @@ export function ModalContainer() {
101
125
  return (_jsx(MessageModal, { title: modal.title, message: modal.message, variant: modal.variant, onDismiss: modal.onDismiss }));
102
126
  case "loading":
103
127
  return _jsx(LoadingModal, { message: modal.message });
128
+ case "version-mismatch":
129
+ return (_jsx(VersionMismatchModal, { mismatches: modal.mismatches, defaultIndex: selectIndex }));
104
130
  default:
105
131
  return null;
106
132
  }
@@ -6,6 +6,7 @@ import { InputModal } from "./InputModal.js";
6
6
  import { SelectModal } from "./SelectModal.js";
7
7
  import { MessageModal } from "./MessageModal.js";
8
8
  import { LoadingModal } from "./LoadingModal.js";
9
+ import { VersionMismatchModal } from "./VersionMismatchModal.js";
9
10
 
10
11
  /**
11
12
  * Container that renders the active modal as an overlay
@@ -28,6 +29,8 @@ export function ModalContainer() {
28
29
  modalRef.current = modal;
29
30
  if (modal?.type === "select") {
30
31
  setSelectIndex(modal.defaultIndex ?? 0);
32
+ } else if (modal?.type === "version-mismatch") {
33
+ setSelectIndex(0);
31
34
  }
32
35
  }
33
36
 
@@ -42,6 +45,7 @@ export function ModalContainer() {
42
45
  else if (modal.type === "input") modal.onCancel();
43
46
  else if (modal.type === "select") modal.onCancel();
44
47
  else if (modal.type === "message") modal.onDismiss();
48
+ else if (modal.type === "version-mismatch") modal.onDismiss();
45
49
  return;
46
50
  }
47
51
 
@@ -50,6 +54,7 @@ export function ModalContainer() {
50
54
  if (modal.type === "confirm") modal.onCancel();
51
55
  else if (modal.type === "select") modal.onCancel();
52
56
  else if (modal.type === "message") modal.onDismiss();
57
+ else if (modal.type === "version-mismatch") modal.onDismiss();
53
58
  return;
54
59
  }
55
60
 
@@ -70,6 +75,19 @@ export function ModalContainer() {
70
75
  return;
71
76
  }
72
77
 
78
+ // Version-mismatch modal — handle navigation and selection (2 options)
79
+ if (modal.type === "version-mismatch") {
80
+ if (key.name === "return" || key.name === "enter") {
81
+ if (selectIndex === 0) modal.onFix();
82
+ else modal.onDismiss();
83
+ } else if (key.name === "up" || key.name === "k") {
84
+ setSelectIndex((prev) => Math.max(0, prev - 1));
85
+ } else if (key.name === "down" || key.name === "j") {
86
+ setSelectIndex((prev) => Math.min(1, prev + 1));
87
+ }
88
+ return;
89
+ }
90
+
73
91
  // Message modal — Enter to dismiss
74
92
  if (modal.type === "message") {
75
93
  if (key.name === "return" || key.name === "enter") {
@@ -137,6 +155,14 @@ export function ModalContainer() {
137
155
  case "loading":
138
156
  return <LoadingModal message={modal.message} />;
139
157
 
158
+ case "version-mismatch":
159
+ return (
160
+ <VersionMismatchModal
161
+ mismatches={modal.mismatches}
162
+ defaultIndex={selectIndex}
163
+ />
164
+ );
165
+
140
166
  default:
141
167
  return null;
142
168
  }
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ const COLOR_BG = "#1C1C1E";
3
+ const COLOR_BORDER = "#525252";
4
+ const COLOR_TITLE = "#EDEDED";
5
+ const COLOR_MUTED = "#A1A1AA";
6
+ const COLOR_DIM = "#71717A";
7
+ const COLOR_WARN = "#FBBF24";
8
+ const COLOR_BAD = "#F87171";
9
+ const COLOR_GOOD = "#4ADE80";
10
+ const COLOR_ACCENT = "#F4F4F5";
11
+ /** Truncate a plugin name to fit the column width */
12
+ function shortName(pluginId, max) {
13
+ const name = pluginId.split("@")[0];
14
+ if (name.length <= max)
15
+ return name;
16
+ return `${name.slice(0, max - 1)}…`;
17
+ }
18
+ /** Truncate a version string to fit the column width */
19
+ function shortVersion(v, max) {
20
+ if (v.length <= max)
21
+ return v;
22
+ return `${v.slice(0, max - 1)}…`;
23
+ }
24
+ export function VersionMismatchModal({ mismatches, defaultIndex, }) {
25
+ const selectedIndex = defaultIndex ?? 0;
26
+ // Column widths chosen to fit a 64-wide modal with padding
27
+ const NAME_W = 22;
28
+ const VER_W = 12;
29
+ return (_jsxs("box", { flexDirection: "column", border: true, borderStyle: "rounded", borderColor: COLOR_BORDER, backgroundColor: COLOR_BG, paddingLeft: 3, paddingRight: 3, paddingTop: 1, paddingBottom: 1, width: 64, children: [_jsx("box", { marginBottom: 1, children: _jsxs("text", { fg: COLOR_TITLE, children: [_jsx("span", { fg: COLOR_WARN, children: "\u26A0 " }), _jsx("strong", { children: "Plugin Version Mismatch" })] }) }), _jsxs("box", { flexDirection: "column", marginBottom: 1, children: [_jsxs("text", { fg: COLOR_MUTED, children: ["Plugins in this project will load ", _jsx("strong", { children: "wrong versions" })] }), _jsx("text", { fg: COLOR_MUTED, children: "due to a Claude Code bug (#45997)." })] }), _jsxs("box", { flexDirection: "column", marginBottom: 1, children: [_jsx("text", { fg: COLOR_DIM, children: _jsxs("strong", { children: ["Plugin".padEnd(NAME_W), "Loaded".padEnd(VER_W), "Expected".padEnd(VER_W)] }) }), _jsxs("text", { fg: COLOR_BORDER, children: ["─".repeat(NAME_W - 1), " ", "─".repeat(VER_W - 1), " ", "─".repeat(VER_W - 1)] }), mismatches.map((m) => {
30
+ const name = shortName(m.pluginId, NAME_W - 1);
31
+ const loaded = `v${shortVersion(m.firstEntryVersion, VER_W - 2)}`;
32
+ const expected = `v${shortVersion(m.currentProjectVersion, VER_W - 2)}`;
33
+ return (_jsxs("text", { fg: COLOR_MUTED, children: [_jsx("span", { fg: COLOR_ACCENT, children: name.padEnd(NAME_W) }), _jsx("span", { fg: COLOR_BAD, children: loaded.padEnd(VER_W) }), _jsx("span", { fg: COLOR_GOOD, children: expected.padEnd(VER_W) })] }, m.pluginId));
34
+ })] }), _jsx("box", { marginBottom: 1, children: _jsx("text", { fg: COLOR_DIM, children: "Details: github.com/anthropics/claude-code/issues/45997" }) }), _jsxs("box", { flexDirection: "column", paddingLeft: 1, children: [_jsxs("text", { fg: selectedIndex === 0 ? COLOR_ACCENT : COLOR_MUTED, children: [_jsx("span", { fg: selectedIndex === 0 ? COLOR_ACCENT : COLOR_DIM, children: selectedIndex === 0 ? "❯ " : " " }), selectedIndex === 0 ? (_jsx("strong", { children: "Fix all projects" })) : ("Fix all projects")] }), _jsxs("text", { fg: selectedIndex === 1 ? COLOR_ACCENT : COLOR_MUTED, children: [_jsx("span", { fg: selectedIndex === 1 ? COLOR_ACCENT : COLOR_DIM, children: selectedIndex === 1 ? "❯ " : " " }), selectedIndex === 1 ? _jsx("strong", { children: "Dismiss" }) : "Dismiss"] })] }), _jsx("box", { marginTop: 1, children: _jsx("text", { fg: COLOR_DIM, children: "\u2191\u2193 Select \u2022 \u21B5 Confirm \u2022 Esc Cancel" }) })] }));
35
+ }
36
+ export default VersionMismatchModal;
@@ -0,0 +1,140 @@
1
+ import React from "react";
2
+ import type { VersionMismatchInfo } from "../../../services/plugin-version-check.js";
3
+
4
+ interface VersionMismatchModalProps {
5
+ /** List of version mismatches detected */
6
+ mismatches: VersionMismatchInfo[];
7
+ /** Currently selected option index (0 = Fix, 1 = Dismiss) */
8
+ defaultIndex?: number;
9
+ }
10
+
11
+ const COLOR_BG = "#1C1C1E";
12
+ const COLOR_BORDER = "#525252";
13
+ const COLOR_TITLE = "#EDEDED";
14
+ const COLOR_MUTED = "#A1A1AA";
15
+ const COLOR_DIM = "#71717A";
16
+ const COLOR_WARN = "#FBBF24";
17
+ const COLOR_BAD = "#F87171";
18
+ const COLOR_GOOD = "#4ADE80";
19
+ const COLOR_ACCENT = "#F4F4F5";
20
+
21
+ /** Truncate a plugin name to fit the column width */
22
+ function shortName(pluginId: string, max: number): string {
23
+ const name = pluginId.split("@")[0];
24
+ if (name.length <= max) return name;
25
+ return `${name.slice(0, max - 1)}…`;
26
+ }
27
+
28
+ /** Truncate a version string to fit the column width */
29
+ function shortVersion(v: string, max: number): string {
30
+ if (v.length <= max) return v;
31
+ return `${v.slice(0, max - 1)}…`;
32
+ }
33
+
34
+ export function VersionMismatchModal({
35
+ mismatches,
36
+ defaultIndex,
37
+ }: VersionMismatchModalProps) {
38
+ const selectedIndex = defaultIndex ?? 0;
39
+
40
+ // Column widths chosen to fit a 64-wide modal with padding
41
+ const NAME_W = 22;
42
+ const VER_W = 12;
43
+
44
+ return (
45
+ <box
46
+ flexDirection="column"
47
+ border
48
+ borderStyle="rounded"
49
+ borderColor={COLOR_BORDER}
50
+ backgroundColor={COLOR_BG}
51
+ paddingLeft={3}
52
+ paddingRight={3}
53
+ paddingTop={1}
54
+ paddingBottom={1}
55
+ width={64}
56
+ >
57
+ {/* Title */}
58
+ <box marginBottom={1}>
59
+ <text fg={COLOR_TITLE}>
60
+ <span fg={COLOR_WARN}>⚠ </span>
61
+ <strong>Plugin Version Mismatch</strong>
62
+ </text>
63
+ </box>
64
+
65
+ {/* Description */}
66
+ <box flexDirection="column" marginBottom={1}>
67
+ <text fg={COLOR_MUTED}>
68
+ Plugins in this project will load <strong>wrong versions</strong>
69
+ </text>
70
+ <text fg={COLOR_MUTED}>due to a Claude Code bug (#45997).</text>
71
+ </box>
72
+
73
+ {/* Table header */}
74
+ <box flexDirection="column" marginBottom={1}>
75
+ <text fg={COLOR_DIM}>
76
+ <strong>
77
+ {"Plugin".padEnd(NAME_W)}
78
+ {"Loaded".padEnd(VER_W)}
79
+ {"Expected".padEnd(VER_W)}
80
+ </strong>
81
+ </text>
82
+ <text fg={COLOR_BORDER}>
83
+ {"─".repeat(NAME_W - 1)} {"─".repeat(VER_W - 1)} {"─".repeat(VER_W - 1)}
84
+ </text>
85
+
86
+ {/* Table rows */}
87
+ {mismatches.map((m) => {
88
+ const name = shortName(m.pluginId, NAME_W - 1);
89
+ const loaded = `v${shortVersion(m.firstEntryVersion, VER_W - 2)}`;
90
+ const expected = `v${shortVersion(m.currentProjectVersion, VER_W - 2)}`;
91
+ return (
92
+ <text key={m.pluginId} fg={COLOR_MUTED}>
93
+ <span fg={COLOR_ACCENT}>{name.padEnd(NAME_W)}</span>
94
+ <span fg={COLOR_BAD}>{loaded.padEnd(VER_W)}</span>
95
+ <span fg={COLOR_GOOD}>{expected.padEnd(VER_W)}</span>
96
+ </text>
97
+ );
98
+ })}
99
+ </box>
100
+
101
+ {/* Footer note */}
102
+ <box marginBottom={1}>
103
+ <text fg={COLOR_DIM}>
104
+ Details: github.com/anthropics/claude-code/issues/45997
105
+ </text>
106
+ </box>
107
+
108
+ {/* Options */}
109
+ <box flexDirection="column" paddingLeft={1}>
110
+ <text
111
+ fg={selectedIndex === 0 ? COLOR_ACCENT : COLOR_MUTED}
112
+ >
113
+ <span fg={selectedIndex === 0 ? COLOR_ACCENT : COLOR_DIM}>
114
+ {selectedIndex === 0 ? "❯ " : " "}
115
+ </span>
116
+ {selectedIndex === 0 ? (
117
+ <strong>Fix all projects</strong>
118
+ ) : (
119
+ "Fix all projects"
120
+ )}
121
+ </text>
122
+ <text
123
+ fg={selectedIndex === 1 ? COLOR_ACCENT : COLOR_MUTED}
124
+ >
125
+ <span fg={selectedIndex === 1 ? COLOR_ACCENT : COLOR_DIM}>
126
+ {selectedIndex === 1 ? "❯ " : " "}
127
+ </span>
128
+ {selectedIndex === 1 ? <strong>Dismiss</strong> : "Dismiss"}
129
+ </text>
130
+ </box>
131
+
132
+ {/* Keybind hints */}
133
+ <box marginTop={1}>
134
+ <text fg={COLOR_DIM}>↑↓ Select • ↵ Confirm • Esc Cancel</text>
135
+ </box>
136
+ </box>
137
+ );
138
+ }
139
+
140
+ export default VersionMismatchModal;
@@ -1,3 +1,4 @@
1
1
  export { useAsyncData, useDebouncedAsyncData } from "./useAsyncData.js";
2
2
  export { useKeyboardHandler } from "./useKeyboardHandler.js";
3
3
  export { useKeyboard } from "./useKeyboard.js";
4
+ export { useMismatchModal } from "./useMismatchModal.js";
@@ -1,3 +1,4 @@
1
1
  export { useAsyncData, useDebouncedAsyncData } from "./useAsyncData.js";
2
2
  export { useKeyboardHandler } from "./useKeyboardHandler.js";
3
3
  export { useKeyboard } from "./useKeyboard.js";
4
+ export { useMismatchModal } from "./useMismatchModal.js";
@@ -0,0 +1,77 @@
1
+ import { useCallback } from "react";
2
+ import { useApp } from "../state/AppContext.js";
3
+ import { fixAllPluginVersionMismatches, } from "../../services/plugin-version-check.js";
4
+ /**
5
+ * Shared hook for showing the plugin version mismatch modal.
6
+ *
7
+ * Used in two places:
8
+ * 1. App.tsx — startup check shows mismatches found across all enabled plugins
9
+ * 2. PluginsScreen.tsx — post-install/update check shows a single plugin mismatch
10
+ *
11
+ * Both flows now share the same interactive modal with a "Fix all projects"
12
+ * button, so users never have to restart claudeup to access the fix.
13
+ *
14
+ * The fix uses fixAllPluginVersionMismatches() which rewrites
15
+ * installed_plugins.json in-place to align all entries to the current
16
+ * project's expected versions. After success, shows a hint to restart
17
+ * Claude Code so the new versions actually load (the running session
18
+ * has the old plugin code in memory).
19
+ */
20
+ export function useMismatchModal() {
21
+ const { dispatch } = useApp();
22
+ const show = useCallback((mismatches) => {
23
+ if (mismatches.length === 0)
24
+ return;
25
+ const dismiss = () => dispatch({ type: "HIDE_MODAL" });
26
+ const fix = async () => {
27
+ dispatch({ type: "HIDE_MODAL" });
28
+ dispatch({
29
+ type: "SHOW_PROGRESS",
30
+ state: { message: "Aligning plugin versions across projects..." },
31
+ });
32
+ try {
33
+ const results = await fixAllPluginVersionMismatches(mismatches);
34
+ dispatch({ type: "HIDE_PROGRESS" });
35
+ // Count how many projects were updated across all plugins
36
+ const totalUpdated = Array.from(results.values()).reduce((sum, r) => sum + r.updated, 0);
37
+ dispatch({
38
+ type: "SHOW_MODAL",
39
+ modal: {
40
+ type: "message",
41
+ title: "Plugin Versions Aligned",
42
+ message: `Updated ${totalUpdated} project entr${totalUpdated === 1 ? "y" : "ies"} in ` +
43
+ `installed_plugins.json.\n\n` +
44
+ `Restart Claude Code for the new versions to load. ` +
45
+ `The running session still has the old plugin code in memory.`,
46
+ variant: "success",
47
+ onDismiss: dismiss,
48
+ },
49
+ });
50
+ }
51
+ catch (error) {
52
+ dispatch({ type: "HIDE_PROGRESS" });
53
+ dispatch({
54
+ type: "SHOW_MODAL",
55
+ modal: {
56
+ type: "message",
57
+ title: "Fix Failed",
58
+ message: `Could not align plugin versions: ${error instanceof Error ? error.message : String(error)}\n\n` +
59
+ `Try running claudeup again, or manually update the plugin in each project.`,
60
+ variant: "error",
61
+ onDismiss: dismiss,
62
+ },
63
+ });
64
+ }
65
+ };
66
+ dispatch({
67
+ type: "SHOW_MODAL",
68
+ modal: {
69
+ type: "version-mismatch",
70
+ mismatches,
71
+ onFix: fix,
72
+ onDismiss: dismiss,
73
+ },
74
+ });
75
+ }, [dispatch]);
76
+ return { show };
77
+ }
@@ -0,0 +1,95 @@
1
+ import { useCallback } from "react";
2
+ import { useApp } from "../state/AppContext.js";
3
+ import {
4
+ fixAllPluginVersionMismatches,
5
+ type VersionMismatchInfo,
6
+ } from "../../services/plugin-version-check.js";
7
+
8
+ /**
9
+ * Shared hook for showing the plugin version mismatch modal.
10
+ *
11
+ * Used in two places:
12
+ * 1. App.tsx — startup check shows mismatches found across all enabled plugins
13
+ * 2. PluginsScreen.tsx — post-install/update check shows a single plugin mismatch
14
+ *
15
+ * Both flows now share the same interactive modal with a "Fix all projects"
16
+ * button, so users never have to restart claudeup to access the fix.
17
+ *
18
+ * The fix uses fixAllPluginVersionMismatches() which rewrites
19
+ * installed_plugins.json in-place to align all entries to the current
20
+ * project's expected versions. After success, shows a hint to restart
21
+ * Claude Code so the new versions actually load (the running session
22
+ * has the old plugin code in memory).
23
+ */
24
+ export function useMismatchModal() {
25
+ const { dispatch } = useApp();
26
+
27
+ const show = useCallback(
28
+ (mismatches: VersionMismatchInfo[]) => {
29
+ if (mismatches.length === 0) return;
30
+
31
+ const dismiss = () => dispatch({ type: "HIDE_MODAL" });
32
+
33
+ const fix = async () => {
34
+ dispatch({ type: "HIDE_MODAL" });
35
+ dispatch({
36
+ type: "SHOW_PROGRESS",
37
+ state: { message: "Aligning plugin versions across projects..." },
38
+ });
39
+
40
+ try {
41
+ const results = await fixAllPluginVersionMismatches(mismatches);
42
+ dispatch({ type: "HIDE_PROGRESS" });
43
+
44
+ // Count how many projects were updated across all plugins
45
+ const totalUpdated = Array.from(results.values()).reduce(
46
+ (sum, r) => sum + r.updated,
47
+ 0,
48
+ );
49
+
50
+ dispatch({
51
+ type: "SHOW_MODAL",
52
+ modal: {
53
+ type: "message",
54
+ title: "Plugin Versions Aligned",
55
+ message:
56
+ `Updated ${totalUpdated} project entr${totalUpdated === 1 ? "y" : "ies"} in ` +
57
+ `installed_plugins.json.\n\n` +
58
+ `Restart Claude Code for the new versions to load. ` +
59
+ `The running session still has the old plugin code in memory.`,
60
+ variant: "success",
61
+ onDismiss: dismiss,
62
+ },
63
+ });
64
+ } catch (error) {
65
+ dispatch({ type: "HIDE_PROGRESS" });
66
+ dispatch({
67
+ type: "SHOW_MODAL",
68
+ modal: {
69
+ type: "message",
70
+ title: "Fix Failed",
71
+ message:
72
+ `Could not align plugin versions: ${error instanceof Error ? error.message : String(error)}\n\n` +
73
+ `Try running claudeup again, or manually update the plugin in each project.`,
74
+ variant: "error",
75
+ onDismiss: dismiss,
76
+ },
77
+ });
78
+ }
79
+ };
80
+
81
+ dispatch({
82
+ type: "SHOW_MODAL",
83
+ modal: {
84
+ type: "version-mismatch",
85
+ mismatches,
86
+ onFix: fix,
87
+ onDismiss: dismiss,
88
+ },
89
+ });
90
+ },
91
+ [dispatch],
92
+ );
93
+
94
+ return { show };
95
+ }
@@ -14,7 +14,8 @@ import { saveProfile } from "../../services/profiles.js";
14
14
  import { installPlugin as cliInstallPlugin, uninstallPlugin as cliUninstallPlugin, updatePlugin as cliUpdatePlugin, } from "../../services/claude-cli.js";
15
15
  import { getPluginEnvRequirements, getPluginSourcePath, } from "../../services/plugin-mcp-config.js";
16
16
  import { getPluginSetupFromSource, checkMissingDeps, installPluginDeps, } from "../../services/plugin-setup.js";
17
- import { checkSinglePluginMismatch } from "../../services/plugin-version-check.js";
17
+ import { checkPluginVersionMismatches, checkSinglePluginMismatch, } from "../../services/plugin-version-check.js";
18
+ import { useMismatchModal } from "../hooks/useMismatchModal.js";
18
19
  import { buildPluginBrowserItems, } from "../adapters/pluginsAdapter.js";
19
20
  import { renderPluginRow, renderPluginDetail, } from "../renderers/pluginRenderers.js";
20
21
  export function PluginsScreen() {
@@ -23,6 +24,7 @@ export function PluginsScreen() {
23
24
  const modal = useModal();
24
25
  const progress = useProgress();
25
26
  const dimensions = useDimensions();
27
+ const mismatchModal = useMismatchModal();
26
28
  const isSearchActive = state.isSearching &&
27
29
  state.currentRoute.screen === "plugins" &&
28
30
  !state.modal;
@@ -213,6 +215,8 @@ export function PluginsScreen() {
213
215
  handleUpdate();
214
216
  else if (event.name === "a")
215
217
  handleUpdateAll();
218
+ else if (event.name === "m")
219
+ handleCheckMismatches();
216
220
  else if (event.name === "s")
217
221
  handleSaveAsProfile();
218
222
  });
@@ -234,22 +238,19 @@ export function PluginsScreen() {
234
238
  }
235
239
  };
236
240
  /**
237
- * Check for version mismatch after a plugin update and warn the user.
241
+ * Check for version mismatch after a plugin update and show the
242
+ * interactive mismatch modal with a "Fix all projects" button.
243
+ *
238
244
  * The [0] index bug in Claude Code means the update may not actually
239
- * take effect if another project's entry is at index 0.
245
+ * take effect if another project's entry is at index 0. The shared
246
+ * useMismatchModal hook provides the same fix flow available at startup.
240
247
  */
241
248
  const warnIfVersionMismatch = async (pluginId) => {
242
249
  try {
243
250
  const projectPath = state.projectPath || process.cwd();
244
251
  const mismatch = await checkSinglePluginMismatch(pluginId, projectPath);
245
252
  if (mismatch) {
246
- await modal.message("Version Mismatch Warning", `${pluginId} was updated to v${mismatch.currentProjectVersion} for this project, ` +
247
- `but Claude Code will load v${mismatch.firstEntryVersion} due to a known bug.\n\n` +
248
- `The plugin loader always uses the first entry in installed_plugins.json, ` +
249
- `which belongs to another project.\n\n` +
250
- `Bug: https://github.com/anthropics/claude-code/issues/45997\n\n` +
251
- `To fix: open claudeup and use the version alignment tool, ` +
252
- `or update the plugin in all projects to the same version.`, "error");
253
+ mismatchModal.show([mismatch]);
253
254
  }
254
255
  }
255
256
  catch {
@@ -567,9 +568,21 @@ export function PluginsScreen() {
567
568
  updatedPluginIds.push(plugin.id);
568
569
  }
569
570
  modal.hideModal();
570
- // Check for mismatches on all updated plugins
571
+ // Batch mismatch checks into a single modal
572
+ const projectPath = state.projectPath || process.cwd();
573
+ const allMismatches = [];
571
574
  for (const pluginId of updatedPluginIds) {
572
- await warnIfVersionMismatch(pluginId);
575
+ try {
576
+ const m = await checkSinglePluginMismatch(pluginId, projectPath);
577
+ if (m)
578
+ allMismatches.push(m);
579
+ }
580
+ catch {
581
+ // best-effort
582
+ }
583
+ }
584
+ if (allMismatches.length > 0) {
585
+ mismatchModal.show(allMismatches);
573
586
  }
574
587
  fetchData();
575
588
  }
@@ -578,6 +591,25 @@ export function PluginsScreen() {
578
591
  await modal.message("Error", `Failed to update: ${error}`, "error");
579
592
  }
580
593
  };
594
+ /**
595
+ * Manual on-demand check for plugin version mismatches across projects.
596
+ * Bound to the `m` keybind. Shows a success message if everything is
597
+ * aligned, or the interactive fix modal if any mismatches are found.
598
+ */
599
+ const handleCheckMismatches = async () => {
600
+ try {
601
+ const projectPath = state.projectPath || process.cwd();
602
+ const mismatches = await checkPluginVersionMismatches(projectPath);
603
+ if (mismatches.length === 0) {
604
+ await modal.message("No Mismatches", "All enabled plugins in this project will load their expected versions. No action needed.", "success");
605
+ return;
606
+ }
607
+ mismatchModal.show(mismatches);
608
+ }
609
+ catch (error) {
610
+ await modal.message("Error", `Failed to check mismatches: ${error}`, "error");
611
+ }
612
+ };
581
613
  const handleScopeToggle = async (scope) => {
582
614
  const item = selectableItems[pluginsState.selectedIndex];
583
615
  if (!item || item.kind !== "plugin")
@@ -704,7 +736,7 @@ export function PluginsScreen() {
704
736
  const selectedItem = selectableItems[pluginsState.selectedIndex];
705
737
  const footerHints = isSearchActive
706
738
  ? "type to filter │ Enter:done │ Esc:clear"
707
- : "u/p/l:toggle │ U:update │ a:all │ s:profile │ /:search";
739
+ : "u/p/l:toggle │ U:update │ a:all │ m:mismatches │ s:profile │ /:search";
708
740
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
709
741
  const plugins = pluginsState.plugins.status === "success" ? pluginsState.plugins.data : [];
710
742
  const installedCount = plugins.filter((p) => p.enabled).length;
@@ -38,7 +38,12 @@ import {
38
38
  checkMissingDeps,
39
39
  installPluginDeps,
40
40
  } from "../../services/plugin-setup.js";
41
- import { checkSinglePluginMismatch } from "../../services/plugin-version-check.js";
41
+ import {
42
+ checkPluginVersionMismatches,
43
+ checkSinglePluginMismatch,
44
+ type VersionMismatchInfo,
45
+ } from "../../services/plugin-version-check.js";
46
+ import { useMismatchModal } from "../hooks/useMismatchModal.js";
42
47
  import {
43
48
  buildPluginBrowserItems,
44
49
  type PluginBrowserItem,
@@ -54,6 +59,7 @@ export function PluginsScreen() {
54
59
  const modal = useModal();
55
60
  const progress = useProgress();
56
61
  const dimensions = useDimensions();
62
+ const mismatchModal = useMismatchModal();
57
63
 
58
64
  const isSearchActive =
59
65
  state.isSearching &&
@@ -261,6 +267,7 @@ export function PluginsScreen() {
261
267
  else if (event.name === "d") handleRemoveDeprecated();
262
268
  else if (event.name === "U") handleUpdate();
263
269
  else if (event.name === "a") handleUpdateAll();
270
+ else if (event.name === "m") handleCheckMismatches();
264
271
  else if (event.name === "s") handleSaveAsProfile();
265
272
  });
266
273
 
@@ -290,26 +297,19 @@ export function PluginsScreen() {
290
297
  };
291
298
 
292
299
  /**
293
- * Check for version mismatch after a plugin update and warn the user.
300
+ * Check for version mismatch after a plugin update and show the
301
+ * interactive mismatch modal with a "Fix all projects" button.
302
+ *
294
303
  * The [0] index bug in Claude Code means the update may not actually
295
- * take effect if another project's entry is at index 0.
304
+ * take effect if another project's entry is at index 0. The shared
305
+ * useMismatchModal hook provides the same fix flow available at startup.
296
306
  */
297
307
  const warnIfVersionMismatch = async (pluginId: string): Promise<void> => {
298
308
  try {
299
309
  const projectPath = state.projectPath || process.cwd();
300
310
  const mismatch = await checkSinglePluginMismatch(pluginId, projectPath);
301
311
  if (mismatch) {
302
- await modal.message(
303
- "Version Mismatch Warning",
304
- `${pluginId} was updated to v${mismatch.currentProjectVersion} for this project, ` +
305
- `but Claude Code will load v${mismatch.firstEntryVersion} due to a known bug.\n\n` +
306
- `The plugin loader always uses the first entry in installed_plugins.json, ` +
307
- `which belongs to another project.\n\n` +
308
- `Bug: https://github.com/anthropics/claude-code/issues/45997\n\n` +
309
- `To fix: open claudeup and use the version alignment tool, ` +
310
- `or update the plugin in all projects to the same version.`,
311
- "error",
312
- );
312
+ mismatchModal.show([mismatch]);
313
313
  }
314
314
  } catch {
315
315
  // Non-fatal: mismatch check is best-effort
@@ -722,9 +722,19 @@ export function PluginsScreen() {
722
722
  updatedPluginIds.push(plugin.id);
723
723
  }
724
724
  modal.hideModal();
725
- // Check for mismatches on all updated plugins
725
+ // Batch mismatch checks into a single modal
726
+ const projectPath = state.projectPath || process.cwd();
727
+ const allMismatches: VersionMismatchInfo[] = [];
726
728
  for (const pluginId of updatedPluginIds) {
727
- await warnIfVersionMismatch(pluginId);
729
+ try {
730
+ const m = await checkSinglePluginMismatch(pluginId, projectPath);
731
+ if (m) allMismatches.push(m);
732
+ } catch {
733
+ // best-effort
734
+ }
735
+ }
736
+ if (allMismatches.length > 0) {
737
+ mismatchModal.show(allMismatches);
728
738
  }
729
739
  fetchData();
730
740
  } catch (error) {
@@ -733,6 +743,33 @@ export function PluginsScreen() {
733
743
  }
734
744
  };
735
745
 
746
+ /**
747
+ * Manual on-demand check for plugin version mismatches across projects.
748
+ * Bound to the `m` keybind. Shows a success message if everything is
749
+ * aligned, or the interactive fix modal if any mismatches are found.
750
+ */
751
+ const handleCheckMismatches = async () => {
752
+ try {
753
+ const projectPath = state.projectPath || process.cwd();
754
+ const mismatches = await checkPluginVersionMismatches(projectPath);
755
+ if (mismatches.length === 0) {
756
+ await modal.message(
757
+ "No Mismatches",
758
+ "All enabled plugins in this project will load their expected versions. No action needed.",
759
+ "success",
760
+ );
761
+ return;
762
+ }
763
+ mismatchModal.show(mismatches);
764
+ } catch (error) {
765
+ await modal.message(
766
+ "Error",
767
+ `Failed to check mismatches: ${error}`,
768
+ "error",
769
+ );
770
+ }
771
+ };
772
+
736
773
  const handleScopeToggle = async (scope: "user" | "project" | "local") => {
737
774
  const item = selectableItems[pluginsState.selectedIndex];
738
775
  if (!item || item.kind !== "plugin") return;
@@ -901,7 +938,7 @@ export function PluginsScreen() {
901
938
 
902
939
  const footerHints = isSearchActive
903
940
  ? "type to filter │ Enter:done │ Esc:clear"
904
- : "u/p/l:toggle │ U:update │ a:all │ s:profile │ /:search";
941
+ : "u/p/l:toggle │ U:update │ a:all │ m:mismatches │ s:profile │ /:search";
905
942
 
906
943
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
907
944
  const plugins: PluginInfo[] =
@@ -6,6 +6,7 @@ import type {
6
6
  SkillInfo,
7
7
  } from "../../types/index.js";
8
8
  import type { PluginInfo } from "../../services/plugin-manager.js";
9
+ import type { VersionMismatchInfo } from "../../services/plugin-version-check.js";
9
10
 
10
11
  // ============================================================================
11
12
  // Route Types
@@ -86,6 +87,12 @@ export type ModalState =
86
87
  | {
87
88
  type: "loading";
88
89
  message: string;
90
+ }
91
+ | {
92
+ type: "version-mismatch";
93
+ mismatches: VersionMismatchInfo[];
94
+ onFix: () => void;
95
+ onDismiss: () => void;
89
96
  };
90
97
 
91
98
  // ============================================================================