claudeup 6.5.1 → 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 (78) 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__/enabled-not-installed.test.ts +1 -2
  14. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  15. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  16. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  17. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  18. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  19. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  20. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  21. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  22. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  23. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  24. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  25. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  26. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  27. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  28. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  29. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  30. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  31. package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
  32. package/src/__tests__/plugin-cli-argv.test.ts +1 -1
  33. package/src/__tests__/plugin-row-text.test.ts +336 -0
  34. package/src/__tests__/scope-action.test.ts +69 -19
  35. package/src/__tests__/shell-script-callers.test.ts +8 -8
  36. package/src/__tests__/tool-probe.test.ts +1400 -0
  37. package/src/__tests__/update-apply.test.ts +283 -1
  38. package/src/__tests__/update-plan.test.ts +773 -31
  39. package/src/__tests__/version-unknown-state.test.ts +388 -0
  40. package/src/cli/router.ts +0 -5
  41. package/src/cli/update-view.ts +92 -12
  42. package/src/cli/update.ts +114 -38
  43. package/src/services/active-closure.ts +117 -0
  44. package/src/services/catalog-cache-store.ts +6 -2
  45. package/src/services/claude-settings.ts +1 -173
  46. package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
  47. package/src/services/gitignore-fixer.ts +1 -1
  48. package/src/services/marketplace-refresh.ts +30 -0
  49. package/src/services/plugin-manager.ts +200 -283
  50. package/src/services/resolver.ts +7 -2
  51. package/src/services/tool-probe.ts +1637 -0
  52. package/src/services/toolchain.ts +97 -0
  53. package/src/services/update-engine.ts +220 -32
  54. package/src/services/update-plan.ts +640 -160
  55. package/src/services/version-snapshot.ts +1 -1
  56. package/src/types/bun.d.ts +6 -0
  57. package/src/ui/App.tsx +2 -27
  58. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  59. package/src/ui/adapters/pluginsAdapter.ts +57 -0
  60. package/src/ui/components/modals/ModalContainer.tsx +0 -26
  61. package/src/ui/hooks/index.ts +0 -1
  62. package/src/ui/hooks/useGitignoreModal.ts +2 -3
  63. package/src/ui/renderers/cliToolRenderers.tsx +108 -67
  64. package/src/ui/renderers/pluginRenderers.tsx +157 -98
  65. package/src/ui/renderers/pluginRowText.ts +70 -0
  66. package/src/ui/renderers/pluginStatusText.ts +56 -0
  67. package/src/ui/screens/CliToolsScreen.tsx +154 -280
  68. package/src/ui/screens/PluginsScreen.tsx +156 -140
  69. package/src/ui/state/types.ts +0 -7
  70. package/src/__tests__/dual-write-prevention.test.ts +0 -380
  71. package/src/__tests__/gap-fill-versions.test.ts +0 -389
  72. package/src/__tests__/plugin-version-check.test.ts +0 -762
  73. package/src/cli/claude.ts +0 -21
  74. package/src/prerunner/index.ts +0 -500
  75. package/src/services/plugin-version-check.ts +0 -360
  76. package/src/services/update-cache.ts +0 -78
  77. package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
  78. package/src/ui/hooks/useMismatchModal.ts +0 -89
@@ -2,7 +2,7 @@
2
2
  * version-snapshot.ts — detect plugin changes that happened outside claudeup.
3
3
  *
4
4
  * Plugins get updated by things claudeup does not control: Claude Code's own
5
- * `plugin install`/`update`, the `claudeup claude` prerunner, or a direct CLI
5
+ * `plugin install`/`update`, or a direct CLI
6
6
  * call. All of them write `installed_plugins.json`; none of them tell the user
7
7
  * anything the next time claudeup opens, so versions move underfoot silently.
8
8
  *
@@ -7,6 +7,9 @@
7
7
  * payload from `Bun.stdin.stream()` because that is the fastest path on the
8
8
  * hottest code claudeup has (a PreToolUse hook runs on every Agent call), so
9
9
  * the global is declared here rather than the call being rewritten.
10
+ * `services/tool-probe.ts` reads uv's `uv-receipt.toml` with `Bun.TOML.parse`
11
+ * (the receipt decides whether uv's silence may be read as "current"); a
12
+ * missing parser degrades to "cannot tell", so the member is optional.
10
13
  *
11
14
  * Keep this MINIMAL. Every member added here is a member nobody checks against
12
15
  * the real runtime; a full mirror of Bun's API would be a second, worse copy of
@@ -18,4 +21,7 @@ declare namespace Bun {
18
21
  const stdin: {
19
22
  stream(): AsyncIterable<Uint8Array>;
20
23
  };
24
+ const TOML: {
25
+ parse(text: string): unknown;
26
+ };
21
27
  }
package/src/ui/App.tsx CHANGED
@@ -6,17 +6,13 @@ import {
6
6
  migrateMarketplaceRename,
7
7
  recoverMarketplaceSettings,
8
8
  } from "../services/claude-settings.js";
9
- import { checkGitignore } from "../services/gitignore-prerun.js";
9
+ import { checkGitignore } from "../services/gitignore-check.js";
10
10
  import {
11
11
  applyAllSafeFixes,
12
12
  loadGitignoreState,
13
13
  } from "../services/gitignore-service.js";
14
14
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
15
15
  import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
16
- import {
17
- type VersionMismatchInfo,
18
- checkPluginVersionMismatches,
19
- } from "../services/plugin-version-check.js";
20
16
  import {
21
17
  type VersionCheckResult,
22
18
  checkForUpdates,
@@ -26,7 +22,6 @@ import { ProgressBar } from "./components/layout/ProgressBar.js";
26
22
  import { ModalContainer } from "./components/modals/index.js";
27
23
  import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
28
24
  import { useKeyboardHandler } from "./hooks/useKeyboardHandler.js";
29
- import { useMismatchModal } from "./hooks/useMismatchModal.js";
30
25
  import {
31
26
  AliasScreen,
32
27
  CliToolsScreen,
@@ -365,10 +360,6 @@ function AppContent({ onExit }: AppContentProps) {
365
360
  const [showDebug, setShowDebug] = useState(false);
366
361
  const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
367
362
  const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
368
- const [mismatchData, setMismatchData] = useState<
369
- VersionMismatchInfo[] | null
370
- >(null);
371
- const mismatchModal = useMismatchModal();
372
363
  const [gitignoreData, setGitignoreData] = useState<{
373
364
  violationCount: number;
374
365
  safeCount: number;
@@ -400,15 +391,8 @@ function AppContent({ onExit }: AppContentProps) {
400
391
  return () => clearTimeout(timer);
401
392
  }, [recoveryReport]);
402
393
 
403
- // Show mismatch modal when data arrives
404
- useEffect(() => {
405
- if (!mismatchData || mismatchData.length === 0) return;
406
- mismatchModal.show(mismatchData);
407
- setMismatchData(null);
408
- }, [mismatchData, mismatchModal]);
409
-
410
394
  // Show gitignore modal — wait for the modal slot to be free so we don't
411
- // stomp on the mismatch modal that may have just been shown.
395
+ // stomp on another modal that may have just been shown.
412
396
  useEffect(() => {
413
397
  if (!gitignoreData) return;
414
398
  if (state.modal) return;
@@ -482,15 +466,6 @@ function AppContent({ onExit }: AppContentProps) {
482
466
  })
483
467
  .catch(() => {}); // non-fatal
484
468
 
485
- // Check for plugin version mismatches (the [0] index bug)
486
- checkPluginVersionMismatches(process.cwd())
487
- .then((mismatches) => {
488
- if (mismatches.length > 0) {
489
- setMismatchData(mismatches);
490
- }
491
- })
492
- .catch(() => {}); // non-fatal
493
-
494
469
  // Check for gitignore manifest violations and surface a popup if any.
495
470
  // Loads twice (once for count, once when user picks "fix safe") because
496
471
  // the second load happens after edits — keeps detection cheap on startup.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * A CLI Tools row, built from the SAME probe facts and the SAME classifier
3
+ * `claudeup update` plans with.
4
+ *
5
+ * The screen used to carry its own probe — a shell `which`, `npm view` per
6
+ * tool, and a private comparator that read "1.2.3-beta" as "1.2.3" — so the
7
+ * row and the CLI could disagree about the same tool. Now the row's state is
8
+ * `classifyTool(spec, facts)`, and for a tool the active profile declares the
9
+ * spec IS the profile's (`specForCatalogTool`), so the two cannot differ.
10
+ *
11
+ * The same goes for the COMMANDS: a declared tool's row builds them with
12
+ * `binCommandForState`, the rule `claudeup update`'s apply path uses, so the
13
+ * row's target and the row's key can never aim at different versions.
14
+ *
15
+ * Pure: no IO, no React. The screen gathers facts; this only assembles.
16
+ */
17
+
18
+ import type { CliTool } from "../../data/cli-tools.js";
19
+ import {
20
+ cliToolInstall,
21
+ cliToolUpdate,
22
+ } from "../../services/cli-tool-commands.js";
23
+ import {
24
+ type InstallInfo,
25
+ type ToolFacts,
26
+ type ToolStatus,
27
+ classifyTool,
28
+ } from "../../services/tool-probe.js";
29
+ import { binCommandForState } from "../../services/toolchain.js";
30
+ import type { BinRequirement, ResolvedBin } from "../../types/index.js";
31
+ import type { Command } from "../../utils/command-utils.js";
32
+ import type { StatusTone } from "../renderers/pluginStatusText.js";
33
+
34
+ export interface CliToolRow {
35
+ tool: CliTool;
36
+ /** The requirement the tool was probed through (closure → detected → catalogue). */
37
+ spec: BinRequirement;
38
+ /** True until facts arrive. */
39
+ checking: boolean;
40
+ /** Null while checking. */
41
+ status: ToolStatus | null;
42
+ install?: InstallInfo;
43
+ /**
44
+ * What Enter runs for an installed tool. For a tool the active profile
45
+ * declares this is the SAME command `claudeup update` would run for the
46
+ * same row (pin included); otherwise the DETECTED method's updater, so it
47
+ * names the installer the row was probed through.
48
+ */
49
+ updateCommand?: Command;
50
+ /** What Enter runs for a tool that is not installed. */
51
+ installCommand: Command;
52
+ }
53
+
54
+ /**
55
+ * Did this spec come from the resolver's closure — i.e. does the active profile
56
+ * declare this tool?
57
+ *
58
+ * `ResolvedBin` is `BinRequirement` plus `sources`, and only the resolver adds
59
+ * them, so the provenance travels with the spec itself rather than being
60
+ * re-derived here (which would be a second copy of `specForCatalogTool`'s
61
+ * branch 1, free to disagree with it).
62
+ */
63
+ function declaredSpec(spec: BinRequirement): ResolvedBin | null {
64
+ const candidate = spec as ResolvedBin;
65
+ return Array.isArray(candidate.sources) ? candidate : null;
66
+ }
67
+
68
+ export function buildToolRow(
69
+ tool: CliTool,
70
+ spec: BinRequirement,
71
+ facts: ToolFacts | undefined,
72
+ install?: InstallInfo,
73
+ ): CliToolRow {
74
+ const declared = declaredSpec(spec);
75
+ // A declared tool installs at its pin, the way `claudeup update` does; the
76
+ // catalogue entry has no pin to honour.
77
+ const installCommand =
78
+ (declared && binCommandForState("install", null, declared)) ||
79
+ cliToolInstall(tool);
80
+ const base = { tool, spec, installCommand, ...(install ? { install } : {}) };
81
+ if (!facts) return { ...base, checking: true, status: null };
82
+ const status = classifyTool(spec, facts);
83
+ const updateCommand = updateCommandFor(tool, declared, status, install);
84
+ return {
85
+ ...base,
86
+ checking: false,
87
+ status,
88
+ ...(updateCommand ? { updateCommand } : {}),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * The command the row's key runs, from the same rule as the row's state.
94
+ *
95
+ * A declared tool goes through `binCommandForState` — the CLI apply path's own
96
+ * builder over the PROFILE's requirement — so a row whose target is the pin
97
+ * installs the pin. Building `@latest` here (what this used to do, always) let
98
+ * the TUI overshoot a pin the CLI hits exactly, and the tool then classified
99
+ * `unknown / ahead-of-pin` for good: claudeup never downgrades.
100
+ *
101
+ * An undeclared catalogue tool keeps the screen's own rule, "follow how it IS
102
+ * installed": there is no pin to honour, and `pnpm`/`yarn` have no
103
+ * `BinInstaller` counterpart to hand the shared builder.
104
+ */
105
+ function updateCommandFor(
106
+ tool: CliTool,
107
+ declared: ResolvedBin | null,
108
+ status: ToolStatus,
109
+ install: InstallInfo | undefined,
110
+ ): Command | undefined {
111
+ if (!isInstalled(status)) return undefined;
112
+ if (declared) {
113
+ return binCommandForState("upgrade", status.target, declared) ?? undefined;
114
+ }
115
+ if (!install) return undefined;
116
+ return cliToolUpdate(tool, install.primary, install.brewFormula) ?? undefined;
117
+ }
118
+
119
+ /** On PATH, as far as the probe could tell. */
120
+ export function isInstalled(status: ToolStatus | null): boolean {
121
+ return status !== null && status.state !== "install";
122
+ }
123
+
124
+ /** A version for display, with exactly one leading "v". */
125
+ export function displayVersion(v: string): string {
126
+ return `v${v.replace(/^v/i, "")}`;
127
+ }
128
+
129
+ export interface CliToolStatusText {
130
+ /** Text after the version, with its leading space; "" for nothing to say. */
131
+ suffix: string;
132
+ tone: StatusTone;
133
+ }
134
+
135
+ /**
136
+ * The row's status words. An `unknown` shows its note verbatim — the same
137
+ * string `claudeup update` prints for that tool.
138
+ */
139
+ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
140
+ const { status } = row;
141
+ if (row.checking || !status) return { suffix: " ...", tone: "muted" };
142
+ switch (status.state) {
143
+ case "unknown":
144
+ return {
145
+ suffix: ` ? ${status.note ?? "cannot tell whether it is current"}`,
146
+ tone: "warning",
147
+ };
148
+ case "upgrade":
149
+ return {
150
+ suffix: status.target
151
+ ? ` → ${displayVersion(status.target)}`
152
+ : " update available",
153
+ tone: "warning",
154
+ };
155
+ case "install":
156
+ case "current":
157
+ return { suffix: "", tone: "muted" };
158
+ }
159
+ }
160
+
161
+ /** Header counts, from the rows' states. */
162
+ export function countToolRows(rows: readonly CliToolRow[]): {
163
+ installed: number;
164
+ updates: number;
165
+ unverified: number;
166
+ } {
167
+ let installed = 0;
168
+ let updates = 0;
169
+ let unverified = 0;
170
+ for (const row of rows) {
171
+ if (isInstalled(row.status)) installed++;
172
+ if (row.status?.state === "upgrade") updates++;
173
+ if (row.status?.state === "unknown") unverified++;
174
+ }
175
+ return { installed, updates, unverified };
176
+ }
@@ -1,5 +1,7 @@
1
1
  import type { PluginInfo } from "../../services/plugin-manager.js";
2
2
  import type { PluginComponent } from "../../services/skills-manager.js";
3
+ import type { ApplyReporter } from "../../services/update-engine.js";
4
+ import type { PluginUpdateItem } from "../../services/update-plan.js";
3
5
  import type { Marketplace } from "../../types/index.js";
4
6
  import type { PluginContentsEntry } from "../state/types.js";
5
7
 
@@ -37,6 +39,12 @@ export interface PluginPluginItem {
37
39
  isExpandable: boolean;
38
40
  /** True when its component rows follow below. */
39
41
  isExpanded: boolean;
42
+ /**
43
+ * The classifier's answer for this plugin — the same item `claudeup update`
44
+ * plans with. Null for a catalog entry the user is only browsing (neither
45
+ * declared by the profile, installed, nor enabled): it has no state to claim.
46
+ */
47
+ status: PluginUpdateItem | null;
40
48
  }
41
49
 
42
50
  /**
@@ -87,6 +95,8 @@ export interface BuildPluginBrowserItemsArgs {
87
95
  collapsedMarketplaces: Set<string>;
88
96
  expandedPlugins?: Set<string>;
89
97
  pluginContents?: Map<string, PluginContentsEntry>;
98
+ /** `classifyPluginRows` output, by plugin id. A plugin absent from it gets no status. */
99
+ statuses?: ReadonlyMap<string, PluginUpdateItem>;
90
100
  }
91
101
 
92
102
  /**
@@ -111,6 +121,7 @@ function pushPluginItems(
111
121
  marketplace: Marketplace,
112
122
  expandedPlugins: Set<string>,
113
123
  pluginContents: Map<string, PluginContentsEntry>,
124
+ statuses: ReadonlyMap<string, PluginUpdateItem>,
114
125
  ): void {
115
126
  const repo = pluginContentsRepo(plugin, marketplace);
116
127
  const isExpandable = !plugin.isOrphaned && !!repo;
@@ -123,6 +134,7 @@ function pushPluginItems(
123
134
  plugin,
124
135
  isExpandable,
125
136
  isExpanded,
137
+ status: statuses.get(plugin.id) ?? null,
126
138
  });
127
139
 
128
140
  if (!isExpanded) return;
@@ -214,6 +226,47 @@ function isMadAppGang(mp: Marketplace): boolean {
214
226
  return (mp.source.repo ?? "").toLowerCase().startsWith("madappgang/");
215
227
  }
216
228
 
229
+ // ─── "Ran, but nothing moved" ─────────────────────────────────────────────────
230
+
231
+ /**
232
+ * Collect the engine's no-move reasons so the screen can name them.
233
+ *
234
+ * `ApplyResult.unchanged` is the whole point of the read-back: an item that
235
+ * ran, exited 0, and left the machine exactly as it was. The CLI prints
236
+ * `N item(s) ran but did not change:` with each cause; the TUI used to branch
237
+ * on `failed.length` alone, show a success, and refetch a row that had not
238
+ * moved — "press U, press U, forever", which is the experience the read-back
239
+ * was added to remove.
240
+ *
241
+ * The cause itself only ever reaches the reporter (`applyPlugins` formats it as
242
+ * `no change — <why>`), so this captures it there, exactly as `cli/update.ts`
243
+ * does with `finishedDetail`.
244
+ */
245
+ export function noMoveReasons(): {
246
+ reporter: ApplyReporter;
247
+ /** One line per unchanged item, with its cause when one was reported. */
248
+ lines(unchanged: readonly string[]): string;
249
+ } {
250
+ const why = new Map<string, string>();
251
+ return {
252
+ reporter: {
253
+ begin: () => {},
254
+ finish: (_ok, name, detail) => {
255
+ const reason = detail.replace(/^no change — /, "");
256
+ if (reason !== detail) why.set(name, reason);
257
+ },
258
+ detach: (work) => work(),
259
+ },
260
+ lines: (unchanged) =>
261
+ unchanged
262
+ .map((name) => {
263
+ const reason = why.get(name);
264
+ return reason ? `${name}: ${reason}` : name;
265
+ })
266
+ .join("\n"),
267
+ };
268
+ }
269
+
217
270
  /**
218
271
  * Builds the flat list of items for the PluginsScreen list panel.
219
272
  * Extracted from PluginsScreen so it can be tested independently.
@@ -224,6 +277,7 @@ export function buildPluginBrowserItems({
224
277
  collapsedMarketplaces,
225
278
  expandedPlugins = new Set(),
226
279
  pluginContents = new Map(),
280
+ statuses = new Map(),
227
281
  }: BuildPluginBrowserItemsArgs): PluginBrowserItem[] {
228
282
  const pluginsByMarketplace = new Map<string, PluginInfo[]>();
229
283
  for (const plugin of plugins) {
@@ -279,6 +333,7 @@ export function buildPluginBrowserItems({
279
333
  marketplace,
280
334
  expandedPlugins,
281
335
  pluginContents,
336
+ statuses,
282
337
  );
283
338
  }
284
339
  }
@@ -316,6 +371,7 @@ export function buildPluginBrowserItems({
316
371
  communityVirtualMp,
317
372
  expandedPlugins,
318
373
  pluginContents,
374
+ statuses,
319
375
  );
320
376
  }
321
377
  }
@@ -347,6 +403,7 @@ export function buildPluginBrowserItems({
347
403
  marketplace,
348
404
  expandedPlugins,
349
405
  pluginContents,
406
+ statuses,
350
407
  );
351
408
  }
352
409
  }
@@ -6,7 +6,6 @@ import { InputModal } from "./InputModal.js";
6
6
  import { LoadingModal } from "./LoadingModal.js";
7
7
  import { MessageModal } from "./MessageModal.js";
8
8
  import { SelectModal } from "./SelectModal.js";
9
- import { VersionMismatchModal } from "./VersionMismatchModal.js";
10
9
 
11
10
  /**
12
11
  * Container that renders the active modal as an overlay
@@ -29,8 +28,6 @@ export function ModalContainer() {
29
28
  modalRef.current = modal;
30
29
  if (modal?.type === "select") {
31
30
  setSelectIndex(modal.defaultIndex ?? 0);
32
- } else if (modal?.type === "version-mismatch") {
33
- setSelectIndex(0);
34
31
  }
35
32
  }
36
33
 
@@ -45,7 +42,6 @@ export function ModalContainer() {
45
42
  else if (modal.type === "input") modal.onCancel();
46
43
  else if (modal.type === "select") modal.onCancel();
47
44
  else if (modal.type === "message") modal.onDismiss();
48
- else if (modal.type === "version-mismatch") modal.onDismiss();
49
45
  return;
50
46
  }
51
47
 
@@ -54,7 +50,6 @@ export function ModalContainer() {
54
50
  if (modal.type === "confirm") modal.onCancel();
55
51
  else if (modal.type === "select") modal.onCancel();
56
52
  else if (modal.type === "message") modal.onDismiss();
57
- else if (modal.type === "version-mismatch") modal.onDismiss();
58
53
  return;
59
54
  }
60
55
 
@@ -75,19 +70,6 @@ export function ModalContainer() {
75
70
  return;
76
71
  }
77
72
 
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
-
91
73
  // Message modal — Enter to dismiss
92
74
  if (modal.type === "message") {
93
75
  if (key.name === "return" || key.name === "enter") {
@@ -155,14 +137,6 @@ export function ModalContainer() {
155
137
  case "loading":
156
138
  return <LoadingModal message={modal.message} />;
157
139
 
158
- case "version-mismatch":
159
- return (
160
- <VersionMismatchModal
161
- mismatches={modal.mismatches}
162
- defaultIndex={selectIndex}
163
- />
164
- );
165
-
166
140
  default:
167
141
  return null;
168
142
  }
@@ -1,3 +1,2 @@
1
1
  export { useKeyboardHandler } from "./useKeyboardHandler.js";
2
2
  export { useKeyboard } from "./useKeyboard.js";
3
- export { useMismatchModal } from "./useMismatchModal.js";
@@ -3,9 +3,8 @@ import { useApp, useNavigation } from "../state/AppContext.js";
3
3
  import type { ModalState } from "../state/types.js";
4
4
 
5
5
  /**
6
- * Startup gitignore-violation modal. Mirrors `useMismatchModal` so the
7
- * two flows feel identical to the user claudeup launches, detects
8
- * a problem, asks "add missing ignores / open the tab / dismiss".
6
+ * Startup gitignore-violation modal: claudeup launches, detects a problem, and
7
+ * asks "add missing ignores / open the tab / dismiss".
9
8
  *
10
9
  * Pure builder is exported separately for tests.
11
10
  */