claudeup 4.31.0 → 4.32.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.
@@ -42,22 +42,50 @@ export interface PluginChange {
42
42
  }
43
43
 
44
44
  interface SnapshotFile {
45
- /** pluginId -> installedVersion as of the last time claudeup rendered it. */
46
- seen: Record<string, string>;
45
+ /**
46
+ * Legacy flat baseline: pluginId -> version, with no record of which project
47
+ * it was observed from. Kept only so an existing file can be migrated.
48
+ */
49
+ seen?: Record<string, string>;
50
+ /**
51
+ * projectPath -> (pluginId -> installedVersion) as of the last time claudeup
52
+ * rendered that project.
53
+ *
54
+ * Versions are per-project (installed_plugins.json keys installs by
55
+ * projectPath), but this file is global. A single flat map therefore let two
56
+ * projects legitimately on different versions overwrite each other's
57
+ * baseline, and every differing plugin was badged "updated" on the next
58
+ * render — a change that never happened. Keying by project removes the
59
+ * cross-talk entirely.
60
+ */
61
+ byProject?: Record<string, Record<string, string>>;
47
62
  /** Set on the run that first created the baseline. */
48
63
  seededAt: string;
49
64
  updatedAt: string;
50
65
  }
51
66
 
67
+ /**
68
+ * Honour CLAUDE_CONFIG_DIR — the same override Claude Code itself uses.
69
+ *
70
+ * `os.homedir()` reads the passwd database on macOS and ignores $HOME, so a
71
+ * test that only overrides HOME writes to the operator's real snapshot file.
72
+ * That is not hypothetical: it happened while building the tests for this fix.
73
+ */
52
74
  function snapshotPath(): string {
53
- return path.join(os.homedir(), ".claude", "claudeup-version-snapshot.json");
75
+ const configDir =
76
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
77
+ return path.join(configDir, "claudeup-version-snapshot.json");
54
78
  }
55
79
 
56
80
  async function read(): Promise<SnapshotFile | null> {
57
81
  try {
58
82
  const raw = await fs.readFile(snapshotPath(), "utf-8");
59
83
  const data = JSON.parse(raw) as SnapshotFile;
60
- if (!data || typeof data.seen !== "object" || data.seen === null) return null;
84
+ if (!data) return null;
85
+ const hasFlat = typeof data.seen === "object" && data.seen !== null;
86
+ const hasByProject =
87
+ typeof data.byProject === "object" && data.byProject !== null;
88
+ if (!hasFlat && !hasByProject) return null;
61
89
  return data;
62
90
  } catch {
63
91
  return null; // absent or corrupt — treated as "no baseline yet"
@@ -109,10 +137,28 @@ export function diffVersions(
109
137
  return changes;
110
138
  }
111
139
 
112
- /** Load the previous snapshot's version map, or null when no baseline exists. */
113
- export async function loadSeenVersions(): Promise<Record<string, string> | null> {
140
+ /**
141
+ * Load the baseline for one project, or null when that project has none.
142
+ *
143
+ * A project seen for the first time returns null, so `diffVersions` stays
144
+ * silent — the same rule as a first ever run. That is deliberate: opening
145
+ * claudeup in a new project must not badge every plugin as "updated" just
146
+ * because this file has never seen that path before.
147
+ *
148
+ * A pre-migration flat file is adopted as the baseline for whichever project
149
+ * asks first, which is the closest thing to the truth it contains.
150
+ */
151
+ export async function loadSeenVersions(
152
+ projectPath: string,
153
+ ): Promise<Record<string, string> | null> {
114
154
  const data = await read();
115
- return data ? data.seen : null;
155
+ if (!data) return null;
156
+ const scoped = data.byProject?.[projectPath];
157
+ if (scoped) return scoped;
158
+ // Legacy flat baseline, project unknown — use it once, then it is replaced
159
+ // by per-project entries on save.
160
+ if (data.seen && !data.byProject) return data.seen;
161
+ return null;
116
162
  }
117
163
 
118
164
  /**
@@ -121,6 +167,7 @@ export async function loadSeenVersions(): Promise<Record<string, string> | null>
121
167
  * shows again next launch.
122
168
  */
123
169
  export async function saveSeenVersions(
170
+ projectPath: string,
124
171
  current: Record<string, string>,
125
172
  ): Promise<void> {
126
173
  try {
@@ -129,7 +176,9 @@ export async function saveSeenVersions(
129
176
  const existing = await read();
130
177
  const now = new Date().toISOString();
131
178
  const payload: SnapshotFile = {
132
- seen: current,
179
+ // Other projects' baselines are preserved — writing only this project's
180
+ // slot is the entire point of the fix.
181
+ byProject: { ...(existing?.byProject ?? {}), [projectPath]: current },
133
182
  seededAt: existing?.seededAt ?? now,
134
183
  updatedAt: now,
135
184
  };
@@ -136,10 +136,20 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
136
136
 
137
137
  let versionStr = "";
138
138
  if (plugin.isOrphaned) {
139
- versionStr = " deprecated";
139
+ // "deprecated" alone reads as "delete me" — but a plugin that merely moved
140
+ // marketplace is still published, and deleting it loses it for no reason.
141
+ versionStr = plugin.movedTo
142
+ ? ` moved → ${plugin.movedTo}`
143
+ : " deprecated";
140
144
  } else if (notInstalled) {
141
145
  versionStr = " not installed";
142
146
  } else if (hasAnyScope) {
147
+ // "what just changed" and "what is still available" are independent facts,
148
+ // and both can be true at once: the prerunner can move a plugin 2.9.0→3.0.1
149
+ // while the marketplace already offers 3.3.1. These used to share one
150
+ // if/else chain, so the "updated" badge silently swallowed the pending
151
+ // update — and the update only reappeared on the next refresh, which read
152
+ // as the state spontaneously changing. Render them as separate clauses.
143
153
  if (plugin.recentlyUpdatedFrom && shownVersion) {
144
154
  // Show what actually happened, not just where it landed — the update may
145
155
  // have come from Claude Code or the prerunner without ever telling us.
@@ -150,9 +160,15 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
150
160
  versionStr = shownVersion ? ` v${shownVersion} new` : " new";
151
161
  } else if (shownVersion) {
152
162
  versionStr = ` v${shownVersion}`;
153
- if (plugin.hasUpdate && plugin.version) {
154
- versionStr += ` → v${plugin.version}`;
155
- }
163
+ }
164
+
165
+ // Pending update, regardless of which badge above applied.
166
+ if (plugin.hasUpdate && plugin.version && versionStr) {
167
+ versionStr += ` → v${plugin.version} available`;
168
+ } else if (plugin.contentStale && versionStr) {
169
+ // Same version, different files. The version compare says "up to date"
170
+ // and is wrong — only a reinstall delivers the current content.
171
+ versionStr += " stale — reinstall";
156
172
  }
157
173
  }
158
174
 
@@ -7,6 +7,7 @@ import { ScrollableList } from "../components/ScrollableList.js";
7
7
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
8
8
  import { fuzzyFilter } from "../../utils/fuzzy-search.js";
9
9
  import { getAllMarketplaces } from "../../data/marketplaces.js";
10
+ import { clearContentDriftCache } from "../../services/content-drift.js";
10
11
  import {
11
12
  diffVersions,
12
13
  loadSeenVersions,
@@ -30,6 +31,7 @@ import {
30
31
  import { saveProfile } from "../../services/profiles.js";
31
32
  import {
32
33
  installPlugin as cliInstallPlugin,
34
+ repairPlugin as cliRepairPlugin,
33
35
  uninstallPlugin as cliUninstallPlugin,
34
36
  updatePlugin as cliUpdatePlugin,
35
37
  type PluginScope,
@@ -87,7 +89,8 @@ export function PluginsScreen() {
87
89
  for (const p of pluginData) {
88
90
  if (p.installedVersion) current[p.id] = p.installedVersion;
89
91
  }
90
- const previous = await loadSeenVersions();
92
+ const projectPath = state.projectPath || process.cwd();
93
+ const previous = await loadSeenVersions(projectPath);
91
94
  for (const change of diffVersions(previous, current)) {
92
95
  const target = pluginData.find((p) => p.id === change.pluginId);
93
96
  if (!target) continue;
@@ -97,8 +100,10 @@ export function PluginsScreen() {
97
100
  target.recentlyInstalled = true;
98
101
  }
99
102
  }
100
- // Record now so the same change is not reported on the next launch.
101
- void saveSeenVersions(current);
103
+ // Await the write. Fire-and-forget raced the next fetchData() a
104
+ // refresh or a post-action refetch could load the baseline before the
105
+ // previous save landed and re-report the same change.
106
+ await saveSeenVersions(projectPath, current);
102
107
 
103
108
  dispatch({
104
109
  type: "PLUGINS_DATA_SUCCESS",
@@ -352,6 +357,9 @@ export function PluginsScreen() {
352
357
  progress.show(`${p.name}`, p.current, p.total);
353
358
  });
354
359
  clearMarketplaceCache();
360
+ // Drift is answered by diffing against the clone's HEAD, so a refresh
361
+ // that moves HEAD invalidates every cached answer.
362
+ clearContentDriftCache();
355
363
  progress.hide();
356
364
 
357
365
  let message =
@@ -705,17 +713,29 @@ export function PluginsScreen() {
705
713
 
706
714
  const handleUpdate = async () => {
707
715
  const item = selectableItems[pluginsState.selectedIndex];
708
- if (!item || item.kind !== "plugin" || !item.plugin.hasUpdate) return;
716
+ if (!item || item.kind !== "plugin") return;
717
+ if (!item.plugin.hasUpdate && !item.plugin.contentStale) return;
709
718
 
710
719
  const plugin = item.plugin;
711
720
  const scope: PluginScope =
712
721
  pluginsState.scope === "global" ? "user" : "project";
713
722
 
723
+ // No version bump to install over — the files changed underneath an
724
+ // unchanged version, and `plugin install` no-ops on "already installed".
725
+ // Only uninstall+install re-copies. See claude-cli.ts repairPlugin.
726
+ const repairing = !plugin.hasUpdate && plugin.contentStale;
727
+
714
728
  modal.loading(
715
- `Updating ${plugin.name}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
729
+ repairing
730
+ ? `Repairing ${plugin.name}…\nclaude plugin uninstall ${plugin.id} --scope ${scope} && claude plugin install ${plugin.id} --scope ${scope}`
731
+ : `Updating ${plugin.name}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
716
732
  );
717
733
  try {
718
- await cliUpdatePlugin(plugin.id, scope);
734
+ if (repairing) {
735
+ await cliRepairPlugin(plugin.id, scope, state.projectPath);
736
+ } else {
737
+ await cliUpdatePlugin(plugin.id, scope);
738
+ }
719
739
  if (plugin.version) {
720
740
  await saveVersionForScope(plugin.id, plugin.version, scope);
721
741
  }
@@ -731,7 +751,11 @@ export function PluginsScreen() {
731
751
  const handleUpdateAll = async () => {
732
752
  if (pluginsState.plugins.status !== "success") return;
733
753
 
734
- const updatable = pluginsState.plugins.data.filter((p) => p.hasUpdate);
754
+ // Content-stale plugins are included: they need repairing, and excluding
755
+ // them meant "update all" left the machine with silently outdated files.
756
+ const updatable = pluginsState.plugins.data.filter(
757
+ (p) => p.hasUpdate || p.contentStale,
758
+ );
735
759
  if (updatable.length === 0) return;
736
760
 
737
761
  const scope: PluginScope =
@@ -741,10 +765,15 @@ export function PluginsScreen() {
741
765
  try {
742
766
  for (let i = 0; i < updatable.length; i++) {
743
767
  const plugin = updatable[i];
768
+ const repairing = !plugin.hasUpdate && plugin.contentStale;
744
769
  modal.loading(
745
- `Updating ${plugin.name} (${i + 1}/${updatable.length})…\nclaude plugin install ${plugin.id} --scope ${scope}`,
770
+ `${repairing ? "Repairing" : "Updating"} ${plugin.name} (${i + 1}/${updatable.length})…\nclaude plugin install ${plugin.id} --scope ${scope}`,
746
771
  );
747
- await cliUpdatePlugin(plugin.id, scope);
772
+ if (repairing) {
773
+ await cliRepairPlugin(plugin.id, scope, state.projectPath);
774
+ } else {
775
+ await cliUpdatePlugin(plugin.id, scope);
776
+ }
748
777
  if (plugin.version) {
749
778
  await saveVersionForScope(plugin.id, plugin.version, scope);
750
779
  }
@@ -872,19 +901,62 @@ export function PluginsScreen() {
872
901
  if (!item || item.kind !== "plugin" || !item.plugin.isOrphaned) return;
873
902
 
874
903
  const plugin = item.plugin;
904
+
905
+ // A plugin that moved marketplace is not deprecated — it is still
906
+ // published under a new namespace. Offering only "remove" silently drops
907
+ // something the user still wants (the magus → magus-marketing split left
908
+ // seo, instantly, video-editing and image-generate in this state).
909
+ let migrateTo: string | undefined;
910
+ if (plugin.movedTo) {
911
+ const choice = await modal.select(
912
+ "Plugin moved",
913
+ `${plugin.name} is no longer published by "${plugin.marketplace}", but "${plugin.movedTo}" still publishes it.`,
914
+ [
915
+ {
916
+ label: `Migrate to ${plugin.name}@${plugin.movedTo}`,
917
+ value: "migrate",
918
+ },
919
+ { label: "Remove it entirely", value: "remove" },
920
+ ],
921
+ );
922
+ if (choice === null) return;
923
+ if (choice === "migrate") migrateTo = plugin.movedTo;
924
+ }
925
+
875
926
  try {
876
927
  // Remove from all scopes — try all to clean up stale references
877
928
  const scopes: PluginScope[] = ["user", "project", "local"];
929
+ const removedFrom: PluginScope[] = [];
878
930
  for (const scope of scopes) {
879
931
  try {
880
932
  modal.loading(
881
933
  `Removing ${plugin.name} from ${scope}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
882
934
  );
935
+ const wasInstalled =
936
+ scope === "user"
937
+ ? plugin.userScope?.enabled
938
+ : scope === "project"
939
+ ? plugin.projectScope?.enabled
940
+ : plugin.localScope?.enabled;
883
941
  await cliUninstallPlugin(plugin.id, scope, state.projectPath);
942
+ if (wasInstalled) removedFrom.push(scope);
884
943
  } catch {
885
944
  // Ignore errors for scopes where it doesn't exist
886
945
  }
887
946
  }
947
+
948
+ if (migrateTo) {
949
+ // Reinstall under the new namespace in every scope it occupied, so
950
+ // the migration does not quietly change where the plugin is enabled.
951
+ const newId = `${plugin.name}@${migrateTo}`;
952
+ for (const scope of removedFrom.length > 0 ? removedFrom : ["project" as PluginScope]) {
953
+ modal.loading(
954
+ `Installing ${newId} (${scope})…\nclaude plugin install ${newId} --scope ${scope}`,
955
+ );
956
+ await cliInstallPlugin(newId, scope);
957
+ }
958
+ }
959
+
888
960
  modal.hideModal();
889
961
  fetchData();
890
962
  } catch (error) {
@@ -986,7 +1058,7 @@ export function PluginsScreen() {
986
1058
  const installedCount = plugins.filter((p) => p.enabled).length;
987
1059
  const updateCount = plugins.filter(
988
1060
  (p) =>
989
- p.hasUpdate &&
1061
+ (p.hasUpdate || p.contentStale) &&
990
1062
  (p.userScope?.enabled ||
991
1063
  p.projectScope?.enabled ||
992
1064
  p.localScope?.enabled),