claudeup 6.5.1 → 6.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/active-closure.test.ts +184 -0
  3. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  4. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  5. package/src/__tests__/blackbox-parity.test.ts +684 -0
  6. package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
  7. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  8. package/src/__tests__/cli-apply-seams.test.ts +2 -0
  9. package/src/__tests__/cli-tool-commands.test.ts +1 -1
  10. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  11. package/src/__tests__/cli-update-view.test.ts +142 -2
  12. package/src/__tests__/content-drift.test.ts +60 -0
  13. package/src/__tests__/conventions-manager.test.ts +45 -45
  14. package/src/__tests__/enabled-not-installed.test.ts +2 -3
  15. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  16. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  17. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  18. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  19. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  20. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  21. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  22. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  23. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  24. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  25. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  26. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  27. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  28. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  29. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  30. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  31. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  32. package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
  33. package/src/__tests__/models-presets-marketplace.test.ts +141 -0
  34. package/src/__tests__/plugin-cli-argv.test.ts +1 -1
  35. package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
  36. package/src/__tests__/plugin-row-text.test.ts +336 -0
  37. package/src/__tests__/resolver.test.ts +3 -3
  38. package/src/__tests__/scope-action.test.ts +69 -19
  39. package/src/__tests__/shell-script-callers.test.ts +8 -8
  40. package/src/__tests__/tool-probe.test.ts +1400 -0
  41. package/src/__tests__/update-apply.test.ts +283 -1
  42. package/src/__tests__/update-plan.test.ts +773 -31
  43. package/src/__tests__/version-unknown-state.test.ts +388 -0
  44. package/src/cli/router.ts +0 -5
  45. package/src/cli/update-view.ts +92 -12
  46. package/src/cli/update.ts +114 -38
  47. package/src/data/models-presets.ts +10 -11
  48. package/src/data/predefined-profiles.ts +7 -7
  49. package/src/services/active-closure.ts +117 -0
  50. package/src/services/catalog-cache-store.ts +6 -2
  51. package/src/services/claude-settings.ts +2 -174
  52. package/src/services/community-styles.ts +1 -1
  53. package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
  54. package/src/services/gitignore-fixer.ts +1 -1
  55. package/src/services/marketplace-refresh.ts +30 -0
  56. package/src/services/plugin-manager.ts +200 -283
  57. package/src/services/resolver.ts +7 -2
  58. package/src/services/tool-probe.ts +1637 -0
  59. package/src/services/toolchain.ts +97 -0
  60. package/src/services/update-engine.ts +220 -32
  61. package/src/services/update-plan.ts +640 -160
  62. package/src/services/version-snapshot.ts +1 -1
  63. package/src/types/bun.d.ts +6 -0
  64. package/src/ui/App.tsx +2 -27
  65. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  66. package/src/ui/adapters/pluginsAdapter.ts +57 -0
  67. package/src/ui/components/modals/ModalContainer.tsx +0 -26
  68. package/src/ui/hooks/index.ts +0 -1
  69. package/src/ui/hooks/useGitignoreModal.ts +2 -3
  70. package/src/ui/renderers/cliToolRenderers.tsx +108 -67
  71. package/src/ui/renderers/pluginRenderers.tsx +157 -98
  72. package/src/ui/renderers/pluginRowText.ts +70 -0
  73. package/src/ui/renderers/pluginStatusText.ts +56 -0
  74. package/src/ui/screens/CliToolsScreen.tsx +154 -280
  75. package/src/ui/screens/PluginsScreen.tsx +156 -140
  76. package/src/ui/state/types.ts +0 -7
  77. package/src/__tests__/dual-write-prevention.test.ts +0 -380
  78. package/src/__tests__/gap-fill-versions.test.ts +0 -389
  79. package/src/__tests__/plugin-version-check.test.ts +0 -762
  80. package/src/cli/claude.ts +0 -21
  81. package/src/prerunner/index.ts +0 -500
  82. package/src/services/plugin-version-check.ts +0 -360
  83. package/src/services/update-cache.ts +0 -78
  84. package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
  85. package/src/ui/hooks/useMismatchModal.ts +0 -89
@@ -1,24 +1,47 @@
1
1
  /**
2
- * Pure planning for `claudeup update` the dependency-facing update.
2
+ * The ONE place plugin state is decided, and the planning for `claudeup update`.
3
+ *
4
+ * `classifyPlugin` answers "what state is this plugin in, and why" for a plugin
5
+ * id, the version the profile pins it to, and the catalog. Every consumer reads
6
+ * its answer and nothing else: `claudeup update`'s plan, and in the TUI the
7
+ * Plugins row text, the detail panel, the header counts, the `U`/`a` actions
8
+ * and the scope picker. The TUI used to derive the same state from raw
9
+ * `PluginInfo` flags with its own rules, and the two front ends drifted — the
10
+ * same plugin, in the same project, could read green in one and red in the
11
+ * other, or carry a different reason in each.
12
+ *
13
+ * Tools follow the same rule: `planBinUpdates` plans from `classifyTool`
14
+ * (services/tool-probe.ts), the same classifier the TUI's CLI Tools screen
15
+ * renders, so a tool has one state in both front ends.
3
16
  *
4
17
  * `install` makes the machine match the manifest's pins. `update` advances it:
5
18
  * anything the active profile declares but the machine lacks gets installed,
6
19
  * and anything pinned "latest" moves to the newest version the marketplace
7
20
  * catalog offers. Everything here is a pure function of a closure plus captured
8
- * machine state, so the decisions are unit-testable and the command file only
9
- * does IO.
21
+ * machine state, so the decisions are unit-testable and the callers only do IO.
10
22
  *
11
- * The one rule worth stating out loud: a catalog fetch that FAILED must never
12
- * be reported as "up to date". `PluginInfo.updateCheckFailed` exists because a
13
- * rate-limited fetch once compared the installed version against a stale local
14
- * clone, found them equal, and rendered the failure as a clean bill of health.
15
- * That case gets its own action (`unknown`) rather than folding into `current`.
23
+ * The one rule worth stating out loud: "cannot tell" must never be reported as
24
+ * "up to date". `PluginInfo.updateCheckFailed` exists because a rate-limited
25
+ * fetch once compared the installed version against a stale local clone, found
26
+ * them equal, and rendered the failure as a clean bill of health. That case, and
27
+ * every other one where the answer is not provable, gets its own action
28
+ * (`unknown`) rather than folding into `current`.
16
29
  */
17
30
 
18
31
  import type { ProfileSkillRef, ResolvedBin } from "../types/index.js";
19
32
  import type { PluginScope } from "./claude-cli.js";
20
- import type { BinCheckResult } from "./doctor-bins.js";
21
- import { type PluginInfo, compareVersions } from "./plugin-manager.js";
33
+ import {
34
+ type PluginInfo,
35
+ compareVersions,
36
+ isBehind,
37
+ } from "./plugin-manager.js";
38
+ import {
39
+ type ToolFacts,
40
+ type ToolReason,
41
+ type ToolState,
42
+ type ToolStatus,
43
+ classifyTool,
44
+ } from "./tool-probe.js";
22
45
 
23
46
  // ── Plugins ──────────────────────────────────────────────────────────────────
24
47
 
@@ -34,6 +57,36 @@ export type PluginUpdateAction =
34
57
  /** The catalog check failed; we cannot claim either way. */
35
58
  | "unknown";
36
59
 
60
+ /**
61
+ * WHY a plugin is in its state — a machine code, so tests and styling can key
62
+ * on it without parsing the human `note`.
63
+ */
64
+ export type PluginReason =
65
+ /** install: declared by the profile, installed nowhere. */
66
+ | "absent"
67
+ /** install: enabled in a settings file, but no registry row in any scope. */
68
+ | "enabled-not-installed"
69
+ /** unknown: a marketplace we READ does not list it. */
70
+ | "not-offered"
71
+ /** unknown: installed, unlisted, and another marketplace publishes the name. */
72
+ | "moved"
73
+ /** unknown: installed, unlisted, and its marketplace contributed no listed ids. */
74
+ | "marketplace-unread"
75
+ /** unknown: the catalog fetch failed ("latest" pins only). */
76
+ | "catalog-unverified"
77
+ /** unknown: listed, but its marketplace publishes no version for it. */
78
+ | "no-published-version"
79
+ /** unknown: an installed scope cannot be ordered against the target. */
80
+ | "unorderable"
81
+ /** repair: files changed without a version bump. */
82
+ | "content-stale"
83
+ /** update: a scope is provably behind. */
84
+ | "behind"
85
+ /** current|update: an exact pin differs from what the marketplace offers. */
86
+ | "pin-undeliverable"
87
+ /** current: every installed scope is at (or ahead of) the target. */
88
+ | "up-to-date";
89
+
37
90
  export interface PluginUpdateItem {
38
91
  pluginId: string;
39
92
  /** What the profile pins: an exact version, or "latest". */
@@ -45,9 +98,19 @@ export interface PluginUpdateItem {
45
98
  /** Version this item would move to. Null for `current`/`unknown`. */
46
99
  target: string | null;
47
100
  action: PluginUpdateAction;
101
+ /** Why it is in this state. */
102
+ reason: PluginReason;
48
103
  /** Scopes holding an install this action must touch. Empty for a fresh install. */
49
104
  scopes: PluginScope[];
50
- /** Human explanation, set for `unknown` and `repair`. */
105
+ /**
106
+ * The version installed in each scope BEFORE any work, so an apply can tell
107
+ * by reading back whether anything actually moved.
108
+ */
109
+ beforeVersions: Partial<Record<PluginScope, string>>;
110
+ /**
111
+ * The exact human reason. The CLI prints it and the TUI shows it verbatim, so
112
+ * it is the SAME string in both front ends.
113
+ */
51
114
  note?: string;
52
115
  }
53
116
 
@@ -67,21 +130,30 @@ function scopeVersions(
67
130
  return out;
68
131
  }
69
132
 
70
- /** The highest version installed across scopes, or null. */
133
+ /**
134
+ * The highest version installed across scopes, or null.
135
+ *
136
+ * `compareVersions` answers null for an unorderable pair, and null is not
137
+ * `>= 0`, so the reduce keeps the right-hand side when two versions cannot be
138
+ * ranked. "Highest" is not meaningful across commit ids; picking one
139
+ * deterministically is, and every caller only needs *an* installed version.
140
+ */
71
141
  function highestInstalled(info: PluginInfo | undefined): string | null {
72
142
  const versions = scopeVersions(info).map(([, v]) => v);
73
143
  if (versions.length === 0) return null;
74
- return versions.reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b));
144
+ return versions.reduce((a, b) =>
145
+ (compareVersions(a, b) ?? -1) >= 0 ? a : b,
146
+ );
75
147
  }
76
148
 
77
149
  /**
78
150
  * Scopes that are strictly BEHIND `target`.
79
151
  *
80
- * Every scope that holds an outdated install is returned, not just "user". The
81
- * prerunner learned this the expensive way: it detected drift from the current
82
- * project's resolved version, then "fixed" it at user scope and reported
83
- * success, leaving the project row untouched so the same drift reappeared on
84
- * every run.
152
+ * Every scope that holds an outdated install is returned, not just "user".
153
+ * Updating one hardcoded scope does not converge: drift is detected from the
154
+ * CURRENT PROJECT's resolved version, so "fixing" user scope reports success
155
+ * and leaves the project row untouched, and the same drift reappears on the
156
+ * next run.
85
157
  *
86
158
  * This only ever moves FORWARD, and there is deliberately no option to do
87
159
  * otherwise. Nothing here can perform a downgrade: `claude plugin install`
@@ -100,182 +172,567 @@ export function scopesNeeding(
100
172
  info: PluginInfo | undefined,
101
173
  target: string,
102
174
  ): PluginScope[] {
175
+ // `isBehind`, not a raw compare: a scope holding a commit sha that differs
176
+ // from the published one IS behind, even though the two carry no ordering.
177
+ // A raw `compareVersions(...) > 0` reported false for that pair and the
178
+ // scope was silently left alone. An unknown (null) is still left alone —
179
+ // moving a scope we cannot rank would be a guess.
103
180
  return scopeVersions(info)
104
- .filter(([, version]) => compareVersions(target, version) > 0)
181
+ .filter(([, version]) => isBehind(target, version) === true)
105
182
  .map(([scope]) => scope);
106
183
  }
107
184
 
185
+ /** True when some installed scope cannot be ordered against `target` at all. */
186
+ function hasUnorderableScope(
187
+ info: PluginInfo | undefined,
188
+ target: string,
189
+ ): boolean {
190
+ return scopeVersions(info).some(
191
+ ([, version]) => isBehind(target, version) === null,
192
+ );
193
+ }
194
+
195
+ /** The "@marketplace" suffix of an id, or null when it has none to reason about. */
196
+ function marketplaceSuffix(id: string): string | null {
197
+ const at = id.indexOf("@");
198
+ if (at < 0) return null;
199
+ const suffix = id.slice(at);
200
+ return suffix.length < 2 ? null : suffix;
201
+ }
202
+
108
203
  /**
109
- * Decide what to do with every plugin the profile declares.
204
+ * The catalog, indexed for the questions the classifier asks of it.
110
205
  *
111
- * @param pinned closure.plugins pluginId -> exact version | "latest"
112
- * @param catalog getAvailablePlugins() output, keyed by plugin id
206
+ * "Listed" is narrower than "present". `getAvailablePlugins` puts ORPHANS
207
+ * installed or enabled, but published by no marketplace we read — into the same
208
+ * list with `isOrphaned: true`, so a plain `has(id)` is true for a plugin that
209
+ * nothing publishes. Treating an orphan as listed is what let an orphan plan as
210
+ * `current`: its `version` is filled in from its own installed version, so the
211
+ * compare found them equal.
113
212
  */
114
- export function planPluginUpdates(
115
- pinned: Record<string, string>,
116
- catalog: Map<string, PluginInfo>,
117
- ): PluginUpdateItem[] {
118
- const items: PluginUpdateItem[] = [];
213
+ export interface CatalogIndex {
214
+ get(pluginId: string): PluginInfo | undefined;
215
+ /** In the catalog AND not an orphan: a marketplace we read publishes it. */
216
+ isListed(pluginId: string): boolean;
217
+ /** Some OTHER listed id shares this id's "@marketplace" suffix. False for ids without "@". */
218
+ marketplaceIsKnown(pluginId: string): boolean;
219
+ }
119
220
 
120
- for (const [pluginId, pin] of Object.entries(pinned)) {
121
- const info = catalog.get(pluginId);
122
- const installed = highestInstalled(info);
123
- const available = info?.version ?? null;
221
+ export function indexCatalog(
222
+ catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
223
+ ): CatalogIndex {
224
+ const byId: ReadonlyMap<string, PluginInfo> =
225
+ catalog instanceof Map
226
+ ? catalog
227
+ : new Map((catalog as readonly PluginInfo[]).map((p) => [p.id, p]));
124
228
 
125
- const base = { pluginId, pinned: pin, installed, available };
229
+ // Listed ids per marketplace suffix, counted once. The previous inline check
230
+ // scanned every catalog key for every plugin, which is quadratic in a catalog
231
+ // of ~300 official plugins.
232
+ const listedPerSuffix = new Map<string, number>();
233
+ for (const [id, info] of byId) {
234
+ if (info.isOrphaned) continue;
235
+ const suffix = marketplaceSuffix(id);
236
+ if (suffix)
237
+ listedPerSuffix.set(suffix, (listedPerSuffix.get(suffix) ?? 0) + 1);
238
+ }
126
239
 
127
- // Not installed anywhere the "checked but not installed" case.
128
- if (!installed) {
129
- items.push({
130
- ...base,
131
- target: pin === "latest" ? available : pin,
132
- action: "install",
133
- scopes: [],
134
- });
135
- continue;
136
- }
240
+ const isListed = (id: string): boolean => {
241
+ const info = byId.get(id);
242
+ return !!info && !info.isOrphaned;
243
+ };
137
244
 
138
- // An exact pin is planned against what can ACTUALLY be delivered.
139
- //
140
- // This is dependency management, not a package manager: there is no
141
- // resolver and no version negotiation. `claude plugin install` takes no
142
- // version it fetches whatever the marketplace publishes now — so a pin
143
- // naming anything else is a request nothing can satisfy.
144
- //
145
- // Planning toward the pin regardless is an infinite loop with a progress
146
- // bar: plan 9.9.9 → 4.6.1, install, get 9.9.9 back, replan the same thing
147
- // next run, and fail `--check` every time in between. Targeting the
148
- // deliverable version converges, and the note carries the shortfall so it
149
- // is recorded rather than hidden. `install --check` + `strictVersions`
150
- // remains the place that GATES on pin drift; this command reports it.
151
- //
152
- // `scopesNeeding` only ever moves forward, so a scope already AHEAD of the
153
- // deliverable version is left alone rather than downgraded — the same rule
154
- // as the "latest" path below, because in both cases the target is what the
155
- // marketplace currently publishes.
156
- //
157
- // The decision comes from the PER-SCOPE versions, never from `installed`.
158
- // `installed` is the highest across scopes, so comparing it to the target
159
- // hides the case that matters most: user scope at the target and project
160
- // scope behind it reads as "current" while the project — the scope that
161
- // actually loads for this repo — stays stale.
162
- if (pin !== "latest") {
163
- const deliverable = available ?? pin;
164
- const needing = scopesNeeding(info, deliverable);
165
- items.push({
166
- ...base,
167
- target: deliverable,
168
- action: needing.length === 0 ? "current" : "update",
169
- scopes: needing,
170
- ...(available !== null && available !== pin
171
- ? {
172
- note: `manifest pins ${pin}; the marketplace offers only ${available}`,
173
- }
174
- : {}),
175
- });
176
- continue;
177
- }
245
+ return {
246
+ get: (id) => byId.get(id),
247
+ isListed,
248
+ marketplaceIsKnown: (id) => {
249
+ const suffix = marketplaceSuffix(id);
250
+ if (!suffix) return false;
251
+ const others =
252
+ (listedPerSuffix.get(suffix) ?? 0) - (isListed(id) ? 1 : 0);
253
+ return others > 0;
254
+ },
255
+ };
256
+ }
178
257
 
179
- // "latest" and the catalog is the only source for what that means.
180
- if (info?.updateCheckFailed) {
181
- items.push({
182
- ...base,
183
- target: null,
184
- action: "unknown",
185
- scopes: [],
186
- note:
187
- info.updateCheckFailure?.detail ??
188
- "catalog fetch failed — cannot tell whether a newer version exists",
189
- });
190
- continue;
191
- }
258
+ /** True when a settings file enables the plugin in at least one scope. */
259
+ function enabledAnywhere(info: PluginInfo | undefined): boolean {
260
+ return (
261
+ !!info?.userScope?.enabled ||
262
+ !!info?.projectScope?.enabled ||
263
+ !!info?.localScope?.enabled
264
+ );
265
+ }
192
266
 
193
- // Same version, changed files. There is no version bump to detect, so a
194
- // plain install is a no-op ("Plugin is already installed") and only
195
- // uninstall+install refreshes the content.
196
- if (info?.contentStale) {
197
- items.push({
198
- ...base,
199
- target: installed,
200
- action: "repair",
201
- scopes: scopeVersions(info).map(([scope]) => scope),
202
- note: "files changed without a version bump",
203
- });
204
- continue;
267
+ /**
268
+ * THE plugin classifier. Pure.
269
+ *
270
+ * The rules are evaluated in order and the first match wins. Each branch below
271
+ * returns, so reading top to bottom is reading the precedence.
272
+ *
273
+ * @param pluginId the plugin
274
+ * @param pin what it is judged against: an exact version, or "latest"
275
+ * @param index the catalog — see {@link indexCatalog}
276
+ */
277
+ export function classifyPlugin(
278
+ pluginId: string,
279
+ pin: string,
280
+ index: CatalogIndex,
281
+ ): PluginUpdateItem {
282
+ const info = index.get(pluginId);
283
+ const listed = index.isListed(pluginId);
284
+ const installed = highestInstalled(info);
285
+ // An orphan's `version` is its own installed version (or a stale local
286
+ // clone's), not something a marketplace we read offers — so it is not
287
+ // "available" from anywhere.
288
+ const available = listed ? (info?.version ?? null) : null;
289
+
290
+ const base = {
291
+ pluginId,
292
+ pinned: pin,
293
+ installed,
294
+ available,
295
+ beforeVersions: Object.fromEntries(scopeVersions(info)) as Partial<
296
+ Record<PluginScope, string>
297
+ >,
298
+ };
299
+
300
+ // Declared by the profile, installed nowhere, and its marketplace IS
301
+ // known to us but does not list it. That pin names a plugin that cannot
302
+ // be installed, and saying so is the whole point of this branch.
303
+ //
304
+ // Measured on the reporter's machine: mnemex's profile pinned
305
+ // `agentdev@magus` (retired) and `seo@magus` (moved to magus-marketing),
306
+ // and every run reported "3 install" while two of the three could never
307
+ // succeed — `claude plugin install` had nothing to fetch. Applied, failed,
308
+ // replanned identically, forever, with no diagnostic.
309
+ //
310
+ // "Not offered" is CATALOG MEMBERSHIP, not a missing version. Keying this
311
+ // on `available === null` was wrong twice over, and the second way was
312
+ // worse than the bug it replaced:
313
+ //
314
+ // - 278 of the 292 plugins in `claude-plugins-official` declare no
315
+ // version at all — `feature-dev` among them. They are listed, they are
316
+ // installable, and `available` is null for every one. Treating that as
317
+ // "not offered" made a profile pin for any of them plan `unknown` and
318
+ // never install. Measured on the reporting machine.
319
+ // - An INSTALLED plugin whose catalog version is unreadable is handled
320
+ // further down against what is on disk. Hence `!installed` here.
321
+ //
322
+ // Listing answers the real question directly. The remaining trap is that a
323
+ // marketplace this machine has not registered yet contributes NO entries, so
324
+ // its plugins are also absent — but installing one is exactly what registers
325
+ // the marketplace. `marketplaceIsKnown` separates the two: holding other
326
+ // LISTED ids from that marketplace proves we genuinely read its catalog, so
327
+ // absence is an answer; holding none proves nothing.
328
+ if (!listed && !installed && index.marketplaceIsKnown(pluginId)) {
329
+ return {
330
+ ...base,
331
+ target: null,
332
+ action: "unknown",
333
+ reason: "not-offered",
334
+ scopes: [],
335
+ note: "not offered by any configured marketplace",
336
+ };
337
+ }
338
+
339
+ // Not installed anywhere — the "checked but not installed" case.
340
+ //
341
+ // Nothing is installed in any scope here, so "enabled somewhere" is exactly
342
+ // "enabled but not installed". It is tested directly rather than through
343
+ // `isEnabledButNotInstalled`, which excludes orphans: an orphan that is only
344
+ // a settings key would otherwise read as `absent`, and the TUI renders
345
+ // `absent` as "the profile declares it", which an undeclared orphan does not.
346
+ if (!installed) {
347
+ const brokenEnable = enabledAnywhere(info);
348
+ return {
349
+ ...base,
350
+ target: pin === "latest" ? available : pin,
351
+ action: "install",
352
+ reason: brokenEnable ? "enabled-not-installed" : "absent",
353
+ scopes: [],
354
+ ...(brokenEnable
355
+ ? { note: "enabled in settings, but no files on disk" }
356
+ : {}),
357
+ };
358
+ }
359
+
360
+ // Installed, but no marketplace we read lists it — an orphan.
361
+ //
362
+ // Planning this against its own installed version (which is what an
363
+ // orphan's `version` holds) found the two equal and reported `current`,
364
+ // while the TUI rendered the same plugin red as "deprecated". Neither is
365
+ // the fact. What is known is one of three things, and each gets its own
366
+ // text: it moved to a marketplace that does publish it; a marketplace we
367
+ // did read no longer lists it; or its marketplace contributed nothing we
368
+ // could read, so its absence proves nothing either way.
369
+ if (!listed) {
370
+ const orphan = {
371
+ ...base,
372
+ target: null,
373
+ action: "unknown" as const,
374
+ scopes: [],
375
+ };
376
+ if (info?.movedTo) {
377
+ return {
378
+ ...orphan,
379
+ reason: "moved",
380
+ note: `moved to ${info.movedTo}`,
381
+ };
382
+ }
383
+ if (index.marketplaceIsKnown(pluginId)) {
384
+ return {
385
+ ...orphan,
386
+ reason: "not-offered",
387
+ note: "not offered by any configured marketplace",
388
+ };
205
389
  }
390
+ const mp =
391
+ info?.marketplace || marketplaceSuffix(pluginId)?.slice(1) || pluginId;
392
+ return {
393
+ ...orphan,
394
+ reason: "marketplace-unread",
395
+ note: `its marketplace ${mp} was not read, so whether it still offers this plugin is unknown`,
396
+ };
397
+ }
206
398
 
207
- // Installed, but no marketplace offers it any more. "current" would be a
208
- // claim we cannot support — there is nothing to compare against.
209
- if (!available) {
210
- items.push({
399
+ // An exact pin is planned against what can ACTUALLY be delivered.
400
+ //
401
+ // This is dependency management, not a package manager: there is no
402
+ // resolver and no version negotiation. `claude plugin install` takes no
403
+ // version — it fetches whatever the marketplace publishes now — so a pin
404
+ // naming anything else is a request nothing can satisfy.
405
+ //
406
+ // Planning toward the pin regardless is an infinite loop with a progress
407
+ // bar: plan 9.9.9 → 4.6.1, install, get 9.9.9 back, replan the same thing
408
+ // next run, and fail `--check` every time in between. Targeting the
409
+ // deliverable version converges, and the note carries the shortfall so it
410
+ // is recorded rather than hidden. `install --check` + `strictVersions`
411
+ // remains the place that GATES on pin drift; this command reports it.
412
+ //
413
+ // `scopesNeeding` only ever moves forward, so a scope already AHEAD of the
414
+ // deliverable version is left alone rather than downgraded — the same rule
415
+ // as the "latest" path below, because in both cases the target is what the
416
+ // marketplace currently publishes.
417
+ //
418
+ // The decision comes from the PER-SCOPE versions, never from `installed`.
419
+ // `installed` is the highest across scopes, so comparing it to the target
420
+ // hides the case that matters most: user scope at the target and project
421
+ // scope behind it reads as "current" while the project — the scope that
422
+ // actually loads for this repo — stays stale.
423
+ if (pin !== "latest") {
424
+ const deliverable = available ?? pin;
425
+ const needing = scopesNeeding(info, deliverable);
426
+
427
+ // The same "cannot tell is not current" rule as the "latest" path below.
428
+ // A scope that cannot be ordered against the deliverable contributes
429
+ // nothing to `needing`, and used to fall through to `current` here.
430
+ if (needing.length === 0 && hasUnorderableScope(info, deliverable)) {
431
+ return {
211
432
  ...base,
212
433
  target: null,
213
434
  action: "unknown",
435
+ reason: "unorderable",
214
436
  scopes: [],
215
- note: "not offered by any configured marketplace",
216
- });
217
- continue;
437
+ note:
438
+ available !== null
439
+ ? `installed version cannot be compared with the published ${available}`
440
+ : `installed version cannot be compared with the pin ${pin}`,
441
+ };
218
442
  }
219
443
 
220
- // Again per-scope, and only ever forward.
221
- const needing = scopesNeeding(info, available);
222
- items.push({
444
+ const undeliverable = available !== null && available !== pin;
445
+ return {
223
446
  ...base,
224
- target: needing.length === 0 ? installed : available,
447
+ target: deliverable,
225
448
  action: needing.length === 0 ? "current" : "update",
449
+ reason: undeliverable
450
+ ? "pin-undeliverable"
451
+ : needing.length === 0
452
+ ? "up-to-date"
453
+ : "behind",
226
454
  scopes: needing,
227
- });
455
+ ...(undeliverable
456
+ ? {
457
+ note: `manifest pins ${pin}; the marketplace offers only ${available}`,
458
+ }
459
+ : {}),
460
+ };
228
461
  }
229
462
 
230
- return items.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
463
+ // "latest" and the catalog is the only source for what that means.
464
+ if (info?.updateCheckFailed) {
465
+ return {
466
+ ...base,
467
+ target: null,
468
+ action: "unknown",
469
+ reason: "catalog-unverified",
470
+ scopes: [],
471
+ note:
472
+ info.updateCheckFailure?.detail ??
473
+ "catalog fetch failed — cannot tell whether a newer version exists",
474
+ };
475
+ }
476
+
477
+ // Same version, changed files. There is no version bump to detect, so a
478
+ // plain install is a no-op ("Plugin is already installed") and only
479
+ // uninstall+install refreshes the content.
480
+ if (info?.contentStale) {
481
+ return {
482
+ ...base,
483
+ target: installed,
484
+ action: "repair",
485
+ reason: "content-stale",
486
+ scopes: scopeVersions(info).map(([scope]) => scope),
487
+ note: "files changed without a version bump",
488
+ };
489
+ }
490
+
491
+ // Installed and listed, with nothing to compare against. The answer is
492
+ // `unknown` — "current" is a claim we cannot support — and the note must not
493
+ // confuse this with the withdrawn case (handled above as an orphan): it is
494
+ // still published, still installable; we simply have no number to compare.
495
+ // 278 of the 292 plugins in `claude-plugins-official` are in this state, so
496
+ // reporting them as "not offered by any configured marketplace" told the
497
+ // user their installed, working, currently-published plugin had been
498
+ // withdrawn.
499
+ if (!available) {
500
+ return {
501
+ ...base,
502
+ target: null,
503
+ action: "unknown",
504
+ reason: "no-published-version",
505
+ scopes: [],
506
+ note: "its marketplace publishes no version for this plugin, so there is nothing to compare",
507
+ };
508
+ }
509
+
510
+ // Again per-scope, and only ever forward.
511
+ const needing = scopesNeeding(info, available);
512
+
513
+ // No scope is provably behind — but that is not the same as being current.
514
+ // `scopesNeeding` filters on `isBehind(...) === true`, so a scope whose
515
+ // version cannot be ordered against the catalog contributes nothing and
516
+ // would fall through to `current`, which is precisely the "cannot tell
517
+ // rendered as nothing wrong" this whole change removes.
518
+ //
519
+ // Real and measured: 21 registry rows carry the literal string "unknown"
520
+ // as their version, across 8 `@claude-plugins-official` ids. Against a
521
+ // semver catalog they are unorderable, so they land here every run.
522
+ if (needing.length === 0 && hasUnorderableScope(info, available)) {
523
+ return {
524
+ ...base,
525
+ target: null,
526
+ action: "unknown",
527
+ reason: "unorderable",
528
+ scopes: [],
529
+ note: `installed version cannot be compared with the published ${available}`,
530
+ };
531
+ }
532
+
533
+ return {
534
+ ...base,
535
+ target: needing.length === 0 ? installed : available,
536
+ action: needing.length === 0 ? "current" : "update",
537
+ reason: needing.length === 0 ? "up-to-date" : "behind",
538
+ scopes: needing,
539
+ };
540
+ }
541
+
542
+ /**
543
+ * Decide what to do with every plugin the profile declares.
544
+ *
545
+ * @param pinned closure.plugins — pluginId -> exact version | "latest"
546
+ * @param catalog getAvailablePlugins() output, keyed by plugin id
547
+ */
548
+ export function planPluginUpdates(
549
+ pinned: Record<string, string>,
550
+ catalog: Map<string, PluginInfo>,
551
+ ): PluginUpdateItem[] {
552
+ const index = indexCatalog(catalog);
553
+ return Object.entries(pinned)
554
+ .map(([pluginId, pin]) => classifyPlugin(pluginId, pin, index))
555
+ .sort((a, b) => a.pluginId.localeCompare(b.pluginId));
556
+ }
557
+
558
+ /**
559
+ * The pin a TUI row is judged against.
560
+ *
561
+ * 1. The active profile's pin, when it declares the plugin — the SAME value
562
+ * `claudeup update` plans with, which is what makes the two agree.
563
+ * 2. Otherwise "latest", when the plugin is installed in any scope or enabled
564
+ * in one — what the TUI's own update actions have always planned with, so
565
+ * the row and the action agree.
566
+ * 3. Otherwise null: an undeclared catalog entry the user is only browsing. It
567
+ * gets no status at all. `install` means "the profile wants it", and an
568
+ * undeclared plugin carries no such claim.
569
+ *
570
+ * A consequence the renderer relies on: reason `absent` can only come from
571
+ * rule 1, because rule 2 never assigns a pin to a row that is neither installed
572
+ * nor enabled.
573
+ */
574
+ export function pinForRow(
575
+ info: PluginInfo,
576
+ pins: Readonly<Record<string, string>>,
577
+ ): string | null {
578
+ if (Object.hasOwn(pins, info.id)) return pins[info.id] ?? null;
579
+ if (scopeVersions(info).length > 0 || enabledAnywhere(info)) return "latest";
580
+ return null;
581
+ }
582
+
583
+ /**
584
+ * One status per row that has a pin. Rows whose {@link pinForRow} is null are
585
+ * absent from the map.
586
+ *
587
+ * Computed once per data/pins change, and the same map feeds every consumer —
588
+ * so the row, the detail panel, the header and the scope picker cannot disagree.
589
+ */
590
+ export function classifyPluginRows(
591
+ plugins: readonly PluginInfo[],
592
+ pins: Readonly<Record<string, string>>,
593
+ ): Map<string, PluginUpdateItem> {
594
+ const index = indexCatalog(plugins);
595
+ const out = new Map<string, PluginUpdateItem>();
596
+ for (const info of plugins) {
597
+ const pin = pinForRow(info, pins);
598
+ if (pin === null) continue;
599
+ out.set(info.id, classifyPlugin(info.id, pin, index));
600
+ }
601
+ return out;
602
+ }
603
+
604
+ export interface PluginStateCounts {
605
+ install: number;
606
+ update: number;
607
+ repair: number;
608
+ current: number;
609
+ unknown: number;
610
+ }
611
+
612
+ /** Counts per action. One implementation, so a header cannot recount differently. */
613
+ export function countPluginStates(
614
+ items: Iterable<PluginUpdateItem>,
615
+ ): PluginStateCounts {
616
+ const counts: PluginStateCounts = {
617
+ install: 0,
618
+ update: 0,
619
+ repair: 0,
620
+ current: 0,
621
+ unknown: 0,
622
+ };
623
+ for (const item of items) counts[item.action]++;
624
+ return counts;
625
+ }
626
+
627
+ /**
628
+ * The scope picker's one question: would advancing THIS scope be an update?
629
+ *
630
+ * Only a provable `update` that touches the scope says yes. A scope ahead of the
631
+ * catalog, one that cannot be ordered, and a `repair` all say no — `claude
632
+ * plugin install` on the same version is a no-op, so offering "update" there
633
+ * runs something that changes nothing.
634
+ */
635
+ export function scopeIsBehind(
636
+ status: PluginUpdateItem | null | undefined,
637
+ scope: PluginScope,
638
+ ): boolean {
639
+ return status?.action === "update" && status.scopes.includes(scope);
231
640
  }
232
641
 
233
642
  // ── Binaries ─────────────────────────────────────────────────────────────────
234
643
 
235
- export type BinUpdateAction =
236
- /** Not on PATH. */
237
- | "install"
238
- /** Present and unpinned — re-run the installer to advance it. */
239
- | "upgrade"
240
- /** Present and pinned to an exact version; nothing to advance to. */
241
- | "current";
644
+ /**
645
+ * A tool's action is exactly its state from `classifyTool` — the same word the
646
+ * TUI's CLI Tools screen shows for the same tool.
647
+ */
648
+ export type BinUpdateAction = ToolState;
242
649
 
243
650
  export interface BinUpdateItem {
244
651
  name: string;
245
652
  action: BinUpdateAction;
653
+ /** Why it is in this state. */
654
+ reason: ToolReason;
655
+ /** The exact human reason; the same string the TUI shows. */
656
+ note?: string;
657
+ /**
658
+ * Set when the upgrade will take the tool over from another installer —
659
+ * {@link ToolStatus.adopted}. The row must say so: it is an ordinary
660
+ * `upgrade` otherwise, and the owner changing is not something to discover
661
+ * afterwards.
662
+ */
663
+ adopted?: string;
246
664
  /** The exact pin, when the profile declares one. */
247
665
  version?: string;
666
+ /** The version PATH runs, or null when absent / unreadable. */
667
+ installed: string | null;
668
+ /** The newest version its installer offers, when known. */
669
+ latest: string | null;
670
+ /** The version an install/upgrade aims at, when known. */
671
+ target: string | null;
248
672
  sources: string[];
249
673
  }
250
674
 
251
675
  /**
252
- * Plan binary work from a presence check.
676
+ * Plan binary work from probe facts, through {@link classifyTool}.
677
+ *
678
+ * This used to plan from a presence check alone: present-and-unpinned was an
679
+ * UPGRADE, every run, because nothing could say whether the tool was behind.
680
+ * `brew upgrade tmux` on a current tmux, `uv tool install --force --upgrade`
681
+ * on a current browser-use — each "succeeded", changed nothing, and was
682
+ * planned identically the next time. Now an upgrade is planned only when the
683
+ * installer that owns the running binary says it is behind (or its registry
684
+ * does), and a tool whose state cannot be established is `unknown`, which is
685
+ * shown and never applied.
253
686
  *
254
- * There is no version probe for an arbitrary binary `checkBinaries` answers
255
- * present/absent only so a PINNED bin that is present is left alone rather
256
- * than reinstalled on a guess. An unpinned one is re-run through its installer,
257
- * which is what "update" means for a dependency with no declared version.
687
+ * Facts are matched by name. A bin with no facts entry was never probed, and
688
+ * that is `unknown` too "not checked" is not "fine".
258
689
  */
259
690
  export function planBinUpdates(
260
691
  bins: ResolvedBin[],
261
- checks: BinCheckResult[],
692
+ facts: readonly ToolFacts[],
262
693
  ): BinUpdateItem[] {
263
- const present = new Map(checks.map((c) => [c.name, c.present]));
694
+ const byName = new Map(facts.map((f) => [f.name, f]));
264
695
  return bins.map((bin) => {
696
+ const f = byName.get(bin.name);
697
+ const status: ToolStatus = f
698
+ ? classifyTool(bin, f)
699
+ : {
700
+ name: bin.name,
701
+ state: "unknown",
702
+ reason: "not-probed",
703
+ note: "not probed",
704
+ installed: null,
705
+ latest: null,
706
+ target: null,
707
+ };
265
708
  const item: BinUpdateItem = {
266
709
  name: bin.name,
710
+ action: status.state,
711
+ reason: status.reason,
712
+ installed: status.installed,
713
+ latest: status.latest,
714
+ target: status.target,
267
715
  sources: bin.sources,
268
- action: !present.get(bin.name)
269
- ? "install"
270
- : bin.version
271
- ? "current"
272
- : "upgrade",
273
716
  };
717
+ if (status.note) item.note = status.note;
718
+ if (status.adopted) item.adopted = status.adopted;
274
719
  if (bin.version) item.version = bin.version;
275
720
  return item;
276
721
  });
277
722
  }
278
723
 
724
+ /**
725
+ * Whether a bin item is WORK: an install or an upgrade.
726
+ *
727
+ * The one predicate `applyBins`, `planWorkItems` and `planHasWork` share. The
728
+ * apply loop used to skip only `current` and run everything else, so the day an
729
+ * `unknown` existed it would have been applied — "cannot tell" executed as an
730
+ * upgrade. One predicate means the three cannot disagree about that.
731
+ */
732
+ export function binNeedsWork(item: BinUpdateItem): boolean {
733
+ return item.action === "install" || item.action === "upgrade";
734
+ }
735
+
279
736
  // ── Skills ───────────────────────────────────────────────────────────────────
280
737
 
281
738
  export interface SkillUpdateItem {
@@ -329,7 +786,7 @@ export interface UpdatePlan {
329
786
  export function planHasWork(plan: UpdatePlan): boolean {
330
787
  return (
331
788
  plan.plugins.some((p) => p.action !== "current") ||
332
- plan.bins.some((b) => b.action !== "current") ||
789
+ plan.bins.some(binNeedsWork) ||
333
790
  plan.skills.length > 0
334
791
  );
335
792
  }
@@ -338,24 +795,41 @@ export function planHasWork(plan: UpdatePlan): boolean {
338
795
  * True when something is actually MISSING or KNOWN TO BE BEHIND. This is what
339
796
  * `--check` gates on, and it is deliberately narrower than {@link planHasWork}.
340
797
  *
341
- * The distinction exists because two kinds of item are re-run every time
342
- * without that meaning anything is wrong:
343
- * - a skill, whose manifest ref carries no content hash, so it is re-fetched
344
- * rather than compared;
345
- * - an unpinned binary, for which there is no version probe at all, only
346
- * present/absent.
798
+ * The distinction exists because a skill is re-run every time without that
799
+ * meaning anything is wrong: its manifest ref carries no content hash, so it is
800
+ * re-fetched rather than compared. Counting that as drift made `--check` exit 1
801
+ * forever for any profile that declared a single skill — a gate that can never
802
+ * pass tells you nothing, and would have been switched off in CI within a week.
347
803
  *
348
- * Counting those as drift made `--check` exit 1 forever for any profile that
349
- * declared a single skill a gate that can never pass tells you nothing, and
350
- * would have been switched off in CI within a week.
804
+ * A bin `upgrade` DOES count now. It used to be excluded because an unpinned
805
+ * binary had no version probe and "upgrade" meant "re-run the installer"; it
806
+ * now means the owning installer says the tool is behind, which is drift.
351
807
  *
352
- * `unknown` DOES count: an unverifiable plugin is exactly what a gate must not
353
- * wave through.
808
+ * NEITHER an `unknown` plugin NOR an `unknown` tool counts. "We could not check
809
+ * this" is not evidence of drift, and most unknowns are structural and
810
+ * permanent rather than transient: a plugin whose marketplace publishes no
811
+ * version (278 of the official ones), a tool on PATH but installed by a
812
+ * different npm, `go` absent, a private registry. A gate that exits 1 on those
813
+ * can never pass, so it protects nothing and is switched off. The cost is that
814
+ * an item whose check merely FAILED this run — a rate-limited catalog fetch, a
815
+ * timed-out probe — also passes, so the exit code alone must never be read as
816
+ * "everything verified": the caller prints how many items went unchecked
817
+ * (`summarizePlan`'s `unknownPlugins`/`unknownTools`, rendered by
818
+ * `cli/update-view.ts`), and every unknown row carries its own reason above.
819
+ *
820
+ * Both unknowns are listed positively rather than as `!== "current"`, so a new
821
+ * action has to be classified here deliberately instead of defaulting into the
822
+ * gate.
354
823
  */
355
824
  export function planIsBehind(plan: UpdatePlan): boolean {
356
825
  return (
357
- plan.plugins.some((p) => p.action !== "current") ||
358
- plan.bins.some((b) => b.action === "install") ||
826
+ plan.plugins.some(
827
+ (p) =>
828
+ p.action === "install" ||
829
+ p.action === "update" ||
830
+ p.action === "repair",
831
+ ) ||
832
+ plan.bins.some((b) => b.action === "install" || b.action === "upgrade") ||
359
833
  plan.skills.some((s) => s.action === "install")
360
834
  );
361
835
  }
@@ -366,18 +840,24 @@ export function summarizePlan(plan: UpdatePlan): {
366
840
  updated: number;
367
841
  repaired: number;
368
842
  current: number;
843
+ /** unknownPlugins + unknownTools. */
369
844
  unknown: number;
845
+ unknownPlugins: number;
846
+ unknownTools: number;
370
847
  } {
371
848
  const count = (a: PluginUpdateAction) =>
372
849
  plan.plugins.filter((p) => p.action === a).length;
850
+ const bins = (a: BinUpdateAction) =>
851
+ plan.bins.filter((b) => b.action === a).length;
852
+ const unknownPlugins = count("unknown");
853
+ const unknownTools = bins("unknown");
373
854
  return {
374
- installed:
375
- count("install") + plan.bins.filter((b) => b.action === "install").length,
376
- updated:
377
- count("update") + plan.bins.filter((b) => b.action === "upgrade").length,
855
+ installed: count("install") + bins("install"),
856
+ updated: count("update") + bins("upgrade"),
378
857
  repaired: count("repair"),
379
- current:
380
- count("current") + plan.bins.filter((b) => b.action === "current").length,
381
- unknown: count("unknown"),
858
+ current: count("current") + bins("current"),
859
+ unknown: unknownPlugins + unknownTools,
860
+ unknownPlugins,
861
+ unknownTools,
382
862
  };
383
863
  }