claudeup 6.5.0 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/active-closure.test.ts +184 -0
  3. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  4. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  5. package/src/__tests__/blackbox-parity.test.ts +684 -0
  6. package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
  7. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  8. package/src/__tests__/catalog-notice.test.ts +3 -3
  9. package/src/__tests__/cli-apply-seams.test.ts +2 -0
  10. package/src/__tests__/cli-tool-commands.test.ts +1 -1
  11. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  12. package/src/__tests__/cli-update-view.test.ts +142 -2
  13. package/src/__tests__/content-drift.test.ts +60 -0
  14. package/src/__tests__/enabled-not-installed.test.ts +1 -2
  15. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  16. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  17. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  18. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  19. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  20. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  21. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  22. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  23. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  24. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  25. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  26. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  27. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  28. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  29. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  30. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  31. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  32. package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
  33. package/src/__tests__/marketplace-badge.test.ts +1 -1
  34. package/src/__tests__/marketplaces.test.ts +1 -0
  35. package/src/__tests__/plugin-cli-argv.test.ts +1 -1
  36. package/src/__tests__/plugin-row-text.test.ts +336 -0
  37. package/src/__tests__/scope-action.test.ts +69 -19
  38. package/src/__tests__/shell-script-callers.test.ts +8 -8
  39. package/src/__tests__/tool-probe.test.ts +1400 -0
  40. package/src/__tests__/update-apply.test.ts +283 -1
  41. package/src/__tests__/update-plan.test.ts +773 -31
  42. package/src/__tests__/version-unknown-state.test.ts +388 -0
  43. package/src/cli/router.ts +0 -5
  44. package/src/cli/update-view.ts +92 -12
  45. package/src/cli/update.ts +114 -38
  46. package/src/data/marketplaces.ts +13 -0
  47. package/src/services/active-closure.ts +117 -0
  48. package/src/services/catalog-cache-store.ts +6 -2
  49. package/src/services/claude-settings.ts +1 -173
  50. package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
  51. package/src/services/gitignore-fixer.ts +1 -1
  52. package/src/services/marketplace-refresh.ts +30 -0
  53. package/src/services/plugin-manager.ts +200 -283
  54. package/src/services/resolver.ts +7 -2
  55. package/src/services/tool-probe.ts +1637 -0
  56. package/src/services/toolchain.ts +97 -0
  57. package/src/services/update-engine.ts +220 -32
  58. package/src/services/update-plan.ts +640 -160
  59. package/src/services/version-snapshot.ts +1 -1
  60. package/src/types/bun.d.ts +6 -0
  61. package/src/ui/App.tsx +2 -27
  62. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  63. package/src/ui/adapters/pluginsAdapter.ts +58 -1
  64. package/src/ui/components/modals/ModalContainer.tsx +0 -26
  65. package/src/ui/hooks/index.ts +0 -1
  66. package/src/ui/hooks/useGitignoreModal.ts +2 -3
  67. package/src/ui/renderers/cliToolRenderers.tsx +108 -67
  68. package/src/ui/renderers/pluginRenderers.tsx +157 -98
  69. package/src/ui/renderers/pluginRowText.ts +70 -0
  70. package/src/ui/renderers/pluginStatusText.ts +56 -0
  71. package/src/ui/screens/CliToolsScreen.tsx +154 -280
  72. package/src/ui/screens/PluginsScreen.tsx +156 -140
  73. package/src/ui/state/types.ts +0 -7
  74. package/src/__tests__/dual-write-prevention.test.ts +0 -380
  75. package/src/__tests__/gap-fill-versions.test.ts +0 -389
  76. package/src/__tests__/plugin-version-check.test.ts +0 -762
  77. package/src/cli/claude.ts +0 -21
  78. package/src/prerunner/index.ts +0 -500
  79. package/src/services/plugin-version-check.ts +0 -360
  80. package/src/services/update-cache.ts +0 -78
  81. package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
  82. package/src/ui/hooks/useMismatchModal.ts +0 -89
package/src/cli/update.ts CHANGED
@@ -34,11 +34,14 @@
34
34
  * `cli/update-view.ts` owns every string. Nothing in this file formats.
35
35
  */
36
36
 
37
+ import { selectProfileId } from "../services/active-closure.js";
37
38
  import { isClaudeAvailable } from "../services/claude-cli.js";
38
39
  import { clearContentDriftCache } from "../services/content-drift.js";
39
- import { checkBinaries } from "../services/doctor-bins.js";
40
40
  import { validateManifest } from "../services/manifest.js";
41
- import { refreshRegisteredMarketplaces } from "../services/marketplace-refresh.js";
41
+ import {
42
+ refreshRegisteredMarketplaces,
43
+ reportRegisteredMarketplaces,
44
+ } from "../services/marketplace-refresh.js";
42
45
  import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
43
46
  import {
44
47
  type PluginInfo,
@@ -48,6 +51,7 @@ import {
48
51
  import { resolveProfile } from "../services/resolver.js";
49
52
  import { getInstalledSkillNames } from "../services/skills-manager.js";
50
53
  import { activeProfile } from "../services/symlink-manager.js";
54
+ import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
51
55
  // The apply half lives in services/ so the TUI runs the SAME code — see the
52
56
  // header of update-engine.ts for what drifted while it did not.
53
57
  import {
@@ -77,11 +81,15 @@ import {
77
81
  header,
78
82
  info,
79
83
  note as noteLine,
84
+ nothingToApplyNotice,
80
85
  ok as okLine,
81
86
  planReport,
82
87
  stepFrame,
83
88
  stepRecord,
84
89
  summaryLine,
90
+ uncheckedGateNotice,
91
+ unknownPluginsNotice,
92
+ unknownToolsNotice,
85
93
  warn as warnLine,
86
94
  } from "./update-view.js";
87
95
 
@@ -195,10 +203,13 @@ export async function runUpdateCommand(
195
203
 
196
204
  // Active profile, not the union — "update my dependencies" means the set in
197
205
  // use. A single-profile manifest needs no activation to be unambiguous.
198
- const targetId =
199
- flags.profile ??
200
- (await activeProfile(projectPath)) ??
201
- (profileIds.length === 1 ? profileIds[0] : undefined);
206
+ // Chosen through the same function the TUI uses, so both judge a plugin
207
+ // against the same pin.
208
+ const targetId = selectProfileId(
209
+ profileIds,
210
+ flags.profile,
211
+ await activeProfile(projectPath),
212
+ );
202
213
  if (!targetId) {
203
214
  console.error(
204
215
  failLine(
@@ -246,7 +257,22 @@ export async function runUpdateCommand(
246
257
  // clones first, then drop every cached answer derived from the old HEAD —
247
258
  // skipping this is how a clone sits days behind while every plugin reads as
248
259
  // up to date.
260
+ // ...but NOT under --dry-run or --check. Both promise to write nothing, and
261
+ // `git pull --ff-only` on a clone under ~/.claude/plugins/marketplaces/ is a
262
+ // write — it advances the user's marketplace to a new commit. The help text
263
+ // said "Writes nothing, exits 0" while the run printed "1 refreshed" in its
264
+ // own output, which is where this was caught. A read-only run plans against
265
+ // the clone as it stands; the notice below says so, so an unrefreshed
266
+ // catalog is never mistaken for a current one.
249
267
  const refresh = await runStep(steps.marketplaces, async (say) => {
268
+ if (flags.dryRun || flags.check) {
269
+ say("read-only — selecting, not pulling");
270
+ // Report-only, NOT a skip: the autoUpdate-disabled verdict is a pure
271
+ // read and is the one diagnostic a read-only run must not lose.
272
+ const report = await reportRegisteredMarketplaces();
273
+ steps.marketplaces.detail = `not refreshed (read-only) · ${report.skipped.length} eligible`;
274
+ return report;
275
+ }
250
276
  let settled = 0;
251
277
  return refreshRegisteredMarketplaces([], {
252
278
  onStart: (names) => {
@@ -311,26 +337,39 @@ export async function runUpdateCommand(
311
337
  return map;
312
338
  });
313
339
 
314
- const binChecks = await runStep(steps.binaries, async (say) => {
340
+ // The tool probe: installed version, owner, and the owning installer's own
341
+ // "behind?" answer — the same probe the TUI's CLI Tools screen uses. It used
342
+ // to be a presence check, which is why every present unpinned tool planned
343
+ // as an upgrade on every run.
344
+ const toolFacts = await runStep(steps.binaries, async (say) => {
315
345
  if (closure.bins.length === 0) {
316
346
  steps.binaries.detail = "none declared";
317
347
  return [];
318
348
  }
319
349
  steps.binaries.progress = { done: 0, total: closure.bins.length };
320
- const results = await checkBinaries(
321
- closure.bins,
322
- undefined,
323
- (name, index, total) => {
324
- steps.binaries.progress = { done: index, total };
325
- say(name);
350
+ const facts = await probeTools(closure.bins, REAL_TOOL_PROBE_IO, {
351
+ // `settled`, not `index`: results arrive in completion order, and a
352
+ // meter driven by index would jump backwards.
353
+ onResult: (f, { settled, total }) => {
354
+ steps.binaries.progress = { done: settled, total };
355
+ say(f.name);
326
356
  },
327
- );
328
- const missing = results.filter((r) => !r.present).length;
329
- steps.binaries.detail = `${results.length} checked${
330
- missing > 0 ? ` · ${missing} missing` : ""
331
- }`;
332
- if (missing > 0) steps.binaries.state = "warn";
333
- return results;
357
+ });
358
+ const items = planBinUpdates(closure.bins, facts);
359
+ const n = (a: string) => items.filter((i) => i.action === a).length;
360
+ const behind = n("upgrade");
361
+ const unknown = n("unknown");
362
+ const missing = n("install");
363
+ steps.binaries.detail = [
364
+ `${items.length} checked`,
365
+ behind > 0 ? `${behind} behind` : "",
366
+ unknown > 0 ? `${unknown} unknown` : "",
367
+ missing > 0 ? `${missing} missing` : "",
368
+ ]
369
+ .filter(Boolean)
370
+ .join(" · ");
371
+ if (missing + unknown > 0) steps.binaries.state = "warn";
372
+ return facts;
334
373
  });
335
374
 
336
375
  const installedSkills = await runStep(steps.skills, async (say) => {
@@ -353,7 +392,7 @@ export async function runUpdateCommand(
353
392
  const plan: UpdatePlan = {
354
393
  profileId: targetId,
355
394
  plugins: planPluginUpdates(closure.plugins, catalog),
356
- bins: planBinUpdates(closure.bins, binChecks),
395
+ bins: planBinUpdates(closure.bins, toolFacts),
357
396
  skills: planSkillUpdates(closure.skills, installedSkills),
358
397
  binSpecs: new Map(closure.bins.map((b) => [b.name, b])),
359
398
  };
@@ -361,23 +400,34 @@ export async function runUpdateCommand(
361
400
  for (const line of planReport(plan)) console.log(line);
362
401
 
363
402
  const counts = summarizePlan(plan);
364
- if (counts.unknown > 0) {
365
- console.log("");
366
- console.log(
367
- warnLine(
368
- `${bold(String(counts.unknown))} plugin(s) could not be checked. They are NOT reported as up to date — re-run once the catalog is reachable.`,
369
- ),
370
- );
403
+ const unknownToolNames = plan.bins
404
+ .filter((b) => b.action === "unknown")
405
+ .map((b) => b.name);
406
+ const toolUnknownLine = () =>
407
+ unknownToolsNotice(counts.unknownTools, unknownToolNames);
408
+ if (counts.unknownPlugins > 0 || counts.unknownTools > 0) console.log("");
409
+ if (counts.unknownPlugins > 0) {
410
+ console.log(unknownPluginsNotice(counts.unknownPlugins));
371
411
  }
412
+ if (counts.unknownTools > 0) console.log(toolUnknownLine());
372
413
 
373
414
  // `--check` gates on planIsBehind, not planHasWork. An already-installed
374
415
  // skill is re-fetched on every apply because its manifest ref carries no
375
- // content hash, and an unpinned binary has no version probe at allneither
376
- // is evidence that anything is out of date, and counting them made the gate
377
- // exit 1 forever for any profile declaring a single skill.
416
+ // content hash, which is no evidence that anything is out of datecounting
417
+ // it made the gate exit 1 forever for any profile declaring a single skill.
418
+ //
419
+ // An `unknown` does not fail the gate, for plugins and tools alike (see
420
+ // planIsBehind). The same argument applies to both: a plugin whose
421
+ // marketplace publishes no version, or a tool another npm owns, is unknown
422
+ // permanently, so failing on it is the skill case again — this profile's 5
423
+ // unversioned official plugins made `--check` exit 1 on every run. A tool
424
+ // or plugin something says is BEHIND still does fail it. Because a failed
425
+ // check now passes silently by exit code, the unchecked count is printed
426
+ // beside the verdict; the per-kind notices and each row's reason are above.
378
427
  if (flags.check) {
379
428
  console.log("");
380
429
  if (!planIsBehind(plan)) {
430
+ if (counts.unknown > 0) console.log(uncheckedGateNotice(counts.unknown));
381
431
  console.log(okLine("Nothing missing and nothing behind."));
382
432
  return 0;
383
433
  }
@@ -406,12 +456,11 @@ export async function runUpdateCommand(
406
456
  // fine". But nothing is APPLIED for an unknown — the apply loop skips it —
407
457
  // so reporting on `planHasWork` alone claimed a run had happened when it
408
458
  // had not. Say what is actually true instead.
409
- if (counts.unknown > 0) {
410
- console.log(
411
- warnLine(
412
- `Nothing to apply. ${counts.unknown} plugin(s) could not be checked — re-run once the catalog is reachable.`,
413
- ),
414
- );
459
+ if (counts.unknownPlugins + counts.unknownTools > 0) {
460
+ if (counts.unknownPlugins > 0) {
461
+ console.log(nothingToApplyNotice(counts.unknownPlugins));
462
+ }
463
+ if (counts.unknownTools > 0) console.log(toolUnknownLine());
415
464
  return 0;
416
465
  }
417
466
  console.log(okLine("Everything is up to date."));
@@ -466,6 +515,8 @@ export async function runUpdateCommand(
466
515
  };
467
516
 
468
517
  const applyLive = new LiveRegion();
518
+ /** The last detail each item finished with — the reason, for an unchanged one. */
519
+ const finishedDetail = new Map<string, string>();
469
520
  const reporter: ApplyReporter = {
470
521
  begin: (name) => {
471
522
  state.current = name;
@@ -473,12 +524,17 @@ export async function runUpdateCommand(
473
524
  },
474
525
  finish: (succeeded, name, detail, ms) => {
475
526
  state.done++;
527
+ finishedDetail.set(name, detail);
476
528
  applyLive.note(
477
529
  applyRow(
478
530
  succeeded,
479
531
  name,
480
532
  nameWidth,
481
- succeeded ? dim(detail) : fg(brand.danger, detail),
533
+ !succeeded
534
+ ? fg(brand.danger, detail)
535
+ : detail.startsWith("no change — ")
536
+ ? fg(brand.warning, detail)
537
+ : dim(detail),
482
538
  ms,
483
539
  ),
484
540
  );
@@ -501,9 +557,29 @@ export async function runUpdateCommand(
501
557
  counts.unknown,
502
558
  failed.length,
503
559
  Date.now() - startedAt,
560
+ outcome.unchanged.length,
504
561
  ))
505
562
  console.log(line);
506
563
 
564
+ // Exited 0, read back unmoved. Reported, never fatal — the same precedent
565
+ // as the pin mismatches below: this is a standing property of the machine
566
+ // (a stale tap, a shadowing PATH entry), not something this run did wrong,
567
+ // and it will be replanned. What it must never be is counted as "changed".
568
+ if (outcome.unchanged.length > 0) {
569
+ console.log(
570
+ warnLine(
571
+ `${bold(String(outcome.unchanged.length))} item(s) ran but did not change:`,
572
+ ),
573
+ );
574
+ for (const name of outcome.unchanged) {
575
+ const detail = (finishedDetail.get(name) ?? "").replace(
576
+ /^no change — /,
577
+ "",
578
+ );
579
+ console.log(noteLine(`${name}: ${detail}`));
580
+ }
581
+ }
582
+
507
583
  // An exact pin the installer could not deliver. Reported, never fatal: only
508
584
  // the newest published version is installable, so this is a standing property
509
585
  // of the manifest rather than something this run did wrong, and failing here
@@ -52,6 +52,19 @@ export const defaultMarketplaces: Marketplace[] = [
52
52
  owned: true, // Ours: always listed even when uninstalled, sorted to the top
53
53
  // Not featured: a niche channel, so keep it collapsed by default.
54
54
  },
55
+ {
56
+ name: "magus-alpha",
57
+ displayName: "Magus Alpha",
58
+ source: {
59
+ source: "github",
60
+ repo: "MadAppGang/magus-alpha",
61
+ },
62
+ description:
63
+ "Experimental plugins with evolving interfaces — may change or be withdrawn without notice",
64
+ official: false,
65
+ owned: true, // Ours: always listed even when uninstalled, sorted to the top
66
+ // Not featured: experimental, so keep it collapsed by default.
67
+ },
55
68
  {
56
69
  name: "claude-plugins-official",
57
70
  displayName: "Anthropic Official",
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Which profile is in effect for a project, and what it resolves to.
3
+ *
4
+ * `claudeup update` and the TUI must judge a plugin against the SAME pin, or
5
+ * the same plugin in the same project reads differently in each. They used to
6
+ * pick the profile in different ways — the CLI inline, the TUI not at all (it
7
+ * judged everything against "latest") — so an exact pin could never agree.
8
+ * Both now choose through {@link selectProfileId} over the same manifest and
9
+ * the same active-profile symlink.
10
+ *
11
+ * Read-only. Unlike the CLI's `ensureManifest`, nothing here bootstraps a
12
+ * manifest: a screen opening must never author a file a team commits.
13
+ *
14
+ * NOTHING HERE PRINTS — it runs under the CLI's live region and inside the TUI.
15
+ */
16
+
17
+ import type {
18
+ BinRequirement,
19
+ ProfileManifest,
20
+ ResolvedClosure,
21
+ } from "../types/index.js";
22
+ import { readManifest, validateManifest } from "./manifest.js";
23
+ import { resolveProfile } from "./resolver.js";
24
+ import { activeProfile } from "./symlink-manager.js";
25
+
26
+ /**
27
+ * The profile to use: the explicit one, else the active one, else the only one.
28
+ *
29
+ * Each candidate counts only if the manifest defines it. A stale active-profile
30
+ * symlink naming a profile that was since removed would otherwise be chosen and
31
+ * then fail to resolve, even when the manifest has exactly one profile and the
32
+ * answer is unambiguous.
33
+ */
34
+ export function selectProfileId(
35
+ profileIds: readonly string[],
36
+ explicit: string | undefined,
37
+ active: string | null,
38
+ ): string | null {
39
+ if (explicit !== undefined && profileIds.includes(explicit)) return explicit;
40
+ if (active !== null && profileIds.includes(active)) return active;
41
+ if (profileIds.length === 1) return profileIds[0] ?? null;
42
+ return null;
43
+ }
44
+
45
+ export interface ActiveClosure {
46
+ profileId: string;
47
+ closure: ResolvedClosure;
48
+ }
49
+
50
+ export interface ActiveClosureDeps {
51
+ readManifest(projectPath: string): Promise<ProfileManifest>;
52
+ activeProfile(projectPath: string): Promise<string | null>;
53
+ resolveProfile(
54
+ manifest: ProfileManifest,
55
+ id: string,
56
+ opts?: {
57
+ binResolver?: (pluginId: string) => Promise<BinRequirement[]>;
58
+ },
59
+ ): Promise<ResolvedClosure>;
60
+ }
61
+
62
+ const REAL_DEPS: ActiveClosureDeps = {
63
+ readManifest,
64
+ activeProfile,
65
+ resolveProfile,
66
+ };
67
+
68
+ export interface LoadActiveClosureOptions {
69
+ /**
70
+ * false → `resolveProfile` gets a resolver that returns no requirements, so
71
+ * no plugin's `plugin.json` is read and `closure.bins` holds only the
72
+ * profile's own `cliTools` entries. Default true.
73
+ *
74
+ * The Plugins screen needs only `closure.plugins`. The default resolver
75
+ * locates and reads every declared plugin's `plugin.json`, which is work the
76
+ * screen would pay on every fetch for a field it never reads.
77
+ */
78
+ resolveBins?: boolean;
79
+ deps?: Partial<ActiveClosureDeps>;
80
+ }
81
+
82
+ const NO_BINS = async (): Promise<BinRequirement[]> => [];
83
+
84
+ /**
85
+ * The active profile's closure for a project.
86
+ *
87
+ * Null when there is no manifest, an invalid one, or no profile that can be
88
+ * selected without an explicit choice. Never throws: a caller that gets null
89
+ * judges against "latest" and says so, which is better than a screen that fails
90
+ * to load over a profile it only needed for pins.
91
+ */
92
+ export async function loadActiveClosure(
93
+ projectPath: string,
94
+ opts: LoadActiveClosureOptions = {},
95
+ ): Promise<ActiveClosure | null> {
96
+ const deps: ActiveClosureDeps = { ...REAL_DEPS, ...opts.deps };
97
+ try {
98
+ const manifest = await deps.readManifest(projectPath);
99
+ if (validateManifest(manifest).length > 0) return null;
100
+
101
+ const profileId = selectProfileId(
102
+ Object.keys(manifest.profiles),
103
+ undefined,
104
+ await deps.activeProfile(projectPath),
105
+ );
106
+ if (profileId === null) return null;
107
+
108
+ const closure = await deps.resolveProfile(
109
+ manifest,
110
+ profileId,
111
+ opts.resolveBins === false ? { binResolver: NO_BINS } : {},
112
+ );
113
+ return { profileId, closure };
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
@@ -35,8 +35,12 @@ import type { MarketplacePlugin } from "./marketplace-catalog.js";
35
35
  /**
36
36
  * How long a cached catalog is served before we ask again.
37
37
  *
38
- * Matches the hour that `update-cache.ts` already uses for its own check, so the
39
- * tool has one refresh rhythm rather than two.
38
+ * This is the ONLY refresh rhythm claudeup has, and it is why the TUI and
39
+ * `claudeup update` can disagree about the same plugin for up to an hour:
40
+ * `update` fast-forwards the clones and clears this cache before planning,
41
+ * while the TUI serves whatever is here. That is not a bug in either path, but
42
+ * anything that reports "no update" off a cached catalog has to say the answer
43
+ * is up to an hour old.
40
44
  */
41
45
  export const CATALOG_TTL_MS = 60 * 60 * 1000;
42
46
 
@@ -322,7 +322,7 @@ async function overlayRegistryVersions(
322
322
  // `readSettings(undefined)` already resolves to the current directory, so the
323
323
  // registry lookup has to as well. While it did not, the base map described one
324
324
  // project while the overlay refused to match any project's rows at all, and every
325
- // version was deleted as unbacked — which is why the prerunner and the global
325
+ // version was deleted as unbacked — which is why the global
326
326
  // plugin view, both of which pass no path, saw project-scope plugins as
327
327
  // uninstalled everywhere, not only in a worktree.
328
328
  const target = scope === "user" ? undefined : (projectPath ?? process.cwd());
@@ -677,98 +677,6 @@ export async function saveGlobalInstalledPluginVersion(
677
677
  await updateInstalledPluginsRegistry(pluginId, version, "user");
678
678
  }
679
679
 
680
- /**
681
- * Gap-fill: ensure global installedPluginVersions is at least as high as
682
- * project and local scopes. When a plugin is updated at project scope,
683
- * global may lag behind, causing Claude Code to resolve stale cache paths.
684
- *
685
- * @returns Array of { pluginId, oldVersion, newVersion } for plugins that were bumped
686
- */
687
- export async function gapFillInstalledPluginVersions(
688
- projectPath?: string,
689
- ): Promise<
690
- Array<{ pluginId: string; oldVersion: string; newVersion: string }>
691
- > {
692
- const globalVersions = await getGlobalInstalledPluginVersions();
693
-
694
- // Collect versions from project and local scopes
695
- const allVersions: Record<string, string[]> = {};
696
-
697
- // Start with global versions
698
- for (const [id, ver] of Object.entries(globalVersions)) {
699
- allVersions[id] = [ver];
700
- }
701
-
702
- // Add project scope versions
703
- if (projectPath) {
704
- try {
705
- const settings = await readSettings(projectPath);
706
- for (const [id, ver] of Object.entries(
707
- settings.installedPluginVersions || {},
708
- )) {
709
- if (!allVersions[id]) allVersions[id] = [];
710
- allVersions[id].push(ver);
711
- }
712
- } catch {
713
- /* skip unreadable */
714
- }
715
-
716
- // Add local scope versions
717
- try {
718
- const localSettings = await readLocalSettings(projectPath);
719
- for (const [id, ver] of Object.entries(
720
- localSettings.installedPluginVersions || {},
721
- )) {
722
- if (!allVersions[id]) allVersions[id] = [];
723
- allVersions[id].push(ver);
724
- }
725
- } catch {
726
- /* skip unreadable */
727
- }
728
- }
729
-
730
- // Find plugins where global needs bumping
731
- const bumped: Array<{
732
- pluginId: string;
733
- oldVersion: string;
734
- newVersion: string;
735
- }> = [];
736
-
737
- for (const [pluginId, versions] of Object.entries(allVersions)) {
738
- // Filter to valid semver versions and sort descending
739
- const valid = versions.filter((v) => semver.valid(v));
740
- if (valid.length === 0) continue;
741
-
742
- const highest = valid.sort((a, b) => semver.rcompare(a, b))[0];
743
- const globalVer = globalVersions[pluginId];
744
-
745
- // If global is missing, invalid semver, or lower than the highest, bump it
746
- if (
747
- !globalVer ||
748
- !semver.valid(globalVer) ||
749
- semver.lt(globalVer, highest)
750
- ) {
751
- bumped.push({
752
- pluginId,
753
- oldVersion: globalVer || "(missing)",
754
- newVersion: highest,
755
- });
756
- }
757
- }
758
-
759
- // Apply bumps in a single write
760
- if (bumped.length > 0) {
761
- const settings = await readGlobalSettings();
762
- settings.installedPluginVersions = settings.installedPluginVersions || {};
763
- for (const { pluginId, newVersion } of bumped) {
764
- settings.installedPluginVersions[pluginId] = newVersion;
765
- }
766
- await writeGlobalSettings(settings);
767
- }
768
-
769
- return bumped;
770
- }
771
-
772
680
  export async function removeGlobalInstalledPluginVersion(
773
681
  pluginId: string,
774
682
  ): Promise<void> {
@@ -1362,86 +1270,6 @@ export async function recoverMarketplaceSettings(): Promise<MarketplaceRecoveryR
1362
1270
  return result;
1363
1271
  }
1364
1272
 
1365
- /**
1366
- * Clean up extraKnownMarketplaces entries that are now properly registered
1367
- * in known_marketplaces.json (the official Claude Code marketplace registry).
1368
- *
1369
- * extraKnownMarketplaces was the old claudeup install mechanism. The official
1370
- * way is `claude plugin marketplace add` which writes to known_marketplaces.json.
1371
- * extraKnownMarketplaces should only be used in project settings for team
1372
- * marketplace suggestions — not in user settings as a primary install path.
1373
- *
1374
- * This function removes magus-related entries from extraKnownMarketplaces in
1375
- * both user and project settings when they're already in known_marketplaces.json.
1376
- */
1377
- export async function cleanupExtraKnownMarketplaces(
1378
- projectPath?: string,
1379
- ): Promise<string[]> {
1380
- const known = await readKnownMarketplaces();
1381
- const cleaned: string[] = [];
1382
-
1383
- // Clean user-level settings
1384
- try {
1385
- const globalSettings = await readGlobalSettings();
1386
- if (globalSettings.extraKnownMarketplaces) {
1387
- let changed = false;
1388
- for (const name of Object.keys(globalSettings.extraKnownMarketplaces)) {
1389
- if (known[name]) {
1390
- delete globalSettings.extraKnownMarketplaces[name];
1391
- cleaned.push(`user:${name}`);
1392
- changed = true;
1393
- }
1394
- }
1395
- // Remove the field entirely if empty
1396
- if (
1397
- changed &&
1398
- Object.keys(globalSettings.extraKnownMarketplaces).length === 0
1399
- ) {
1400
- // biome-ignore lint/performance/noDelete: the empty block is removed from settings.json entirely; an undefined value would survive Object.keys and `in` checks
1401
- delete globalSettings.extraKnownMarketplaces;
1402
- }
1403
- if (changed) {
1404
- await writeGlobalSettings(globalSettings);
1405
- }
1406
- }
1407
- } catch {
1408
- // Non-fatal: user settings cleanup is best-effort
1409
- }
1410
-
1411
- // Clean project-level settings
1412
- if (projectPath) {
1413
- try {
1414
- const projectSettings = await readSettings(projectPath);
1415
- if (projectSettings.extraKnownMarketplaces) {
1416
- let changed = false;
1417
- for (const name of Object.keys(
1418
- projectSettings.extraKnownMarketplaces,
1419
- )) {
1420
- if (known[name]) {
1421
- delete projectSettings.extraKnownMarketplaces[name];
1422
- cleaned.push(`project:${name}`);
1423
- changed = true;
1424
- }
1425
- }
1426
- if (
1427
- changed &&
1428
- Object.keys(projectSettings.extraKnownMarketplaces).length === 0
1429
- ) {
1430
- // biome-ignore lint/performance/noDelete: the empty block is removed from settings.json entirely; an undefined value would survive Object.keys and `in` checks
1431
- delete projectSettings.extraKnownMarketplaces;
1432
- }
1433
- if (changed) {
1434
- await writeSettings(projectSettings, projectPath);
1435
- }
1436
- }
1437
- } catch {
1438
- // Non-fatal: project settings cleanup is best-effort
1439
- }
1440
- }
1441
-
1442
- return cleaned;
1443
- }
1444
-
1445
1273
  /**
1446
1274
  * Read known_marketplaces.json to get marketplace source info
1447
1275
  */
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Prerunner integration for the gitignore manager.
2
+ * Read-only gitignore check for the gitignore manager.
3
3
  *
4
- * Called from `prerunner/index.ts` (Step 0.9) on every claudeup invocation.
5
- * Resolves the manifest, runs detection, and prints a concise warning when
6
- * violations exist. Does NOT auto-apply fixes — the user must open the
7
- * Git State tab and confirm. This mirrors the plugin-version-mismatch flow
8
- * where prerun *warns* and the TUI provides the interactive fix dialog.
4
+ * Called from the TUI on startup and from `doctor`. Resolves the manifest, runs
5
+ * detection, and returns a concise warning when violations exist. Does NOT
6
+ * auto-apply fixes — the user must open the Git State tab and confirm. This
7
+ * mirrors the plugin-version-mismatch flow, where the check only *reports* and
8
+ * the TUI provides the interactive fix dialog.
9
9
  */
10
10
 
11
11
  import type { Violation } from "../types/gitignore.js";
@@ -13,12 +13,14 @@ import { detectViolations } from "./gitignore-detector.js";
13
13
  import { formatViolationSummary } from "./gitignore-fixer.js";
14
14
  import { resolveGitignore } from "./gitignore-resolver.js";
15
15
 
16
- export interface PrerunResult {
16
+ export interface GitignoreCheckResult {
17
17
  violationCount: number;
18
18
  warnings: string[];
19
19
  }
20
20
 
21
- export async function checkGitignore(cwd: string): Promise<PrerunResult> {
21
+ export async function checkGitignore(
22
+ cwd: string,
23
+ ): Promise<GitignoreCheckResult> {
22
24
  const warnings: string[] = [];
23
25
  try {
24
26
  const resolved = await resolveGitignore(cwd, {
@@ -38,10 +40,12 @@ export async function checkGitignore(cwd: string): Promise<PrerunResult> {
38
40
  }
39
41
 
40
42
  /**
41
- * One-line summary suitable for prerun stderr. Returns null when there's
43
+ * One-line summary suitable for stderr. Returns null when there's
42
44
  * nothing to warn about.
43
45
  */
44
- export function formatPrerunWarning(result: PrerunResult): string | null {
46
+ export function formatGitignoreWarning(
47
+ result: GitignoreCheckResult,
48
+ ): string | null {
45
49
  if (result.violationCount === 0) return null;
46
50
  return `⚠ ${result.violationCount} git state issue(s) detected. Run: claudeup → Git State tab to review and fix`;
47
51
  }
@@ -12,7 +12,7 @@
12
12
  * Each call handles ONE violation; the caller is responsible for confirming
13
13
  * each one with the user.
14
14
  *
15
- * `formatViolationSummary` is a pure helper for prerun warning + TUI.
15
+ * `formatViolationSummary` is a pure helper for the warning line + TUI.
16
16
  */
17
17
 
18
18
  import { spawn } from "node:child_process";