@@ -88,8 +88,9 @@ export interface MarketplaceManifest {
8888 * Parse the plugin's `installPath` to extract the SHA prefix it was
8989 * pinned to (12 chars). Returns `null` for directory installs,
9090 * version-tagged installs, or any path shape we don't recognize as
91- * SHA-pinned. Used to detect drift between manifest pin and on-disk
92- * install.
91+ * SHA-pinned. Claude Code uses this dir-name shape for ref-less pins;
92+ * version-tagged pins use a dir name like `1.0.1` instead — see
93+ * `lookupInstalledSha` for the authoritative source.
9394 */
9495export function extractInstalledSha (
9596 installPath : string | undefined ,
@@ -100,6 +101,38 @@ export function extractInstalledSha(
100101 return m ? m [ 1 ] ?? null : null
101102}
102103
104+ /**
105+ * Look up the installed `gitCommitSha` for a plugin from Claude Code's
106+ * own state file `~/.claude/plugins/installed_plugins.json`. This is
107+ * the authoritative record of which commit a plugin was installed
108+ * from, regardless of whether the cache dir is SHA-prefixed
109+ * (`9cb4fe40-deadbeef/`) or version-tagged (`1.0.1/`).
110+ *
111+ * Returns the full 40-char SHA, or `null` if the file/entry is missing
112+ * or the `gitCommitSha` field is absent (some plugin sources don't
113+ * carry it — directory installs, for example).
114+ */
115+ export function lookupInstalledSha (
116+ installedPluginsJson : unknown ,
117+ installId : string ,
118+ ) : string | null {
119+ if ( ! installedPluginsJson || typeof installedPluginsJson !== 'object' ) {
120+ return null
121+ }
122+ const plugins = ( installedPluginsJson as { plugins ?: unknown } ) . plugins
123+ if ( ! plugins || typeof plugins !== 'object' ) return null
124+ const entries = ( plugins as Record < string , unknown > ) [ installId ]
125+ if ( ! Array . isArray ( entries ) ) return null
126+ for ( const entry of entries ) {
127+ if ( ! entry || typeof entry !== 'object' ) continue
128+ const sha = ( entry as { gitCommitSha ?: unknown } ) . gitCommitSha
129+ if ( typeof sha === 'string' && / ^ [ 0 - 9 a - f ] { 40 } $ / . test ( sha ) ) {
130+ return sha
131+ }
132+ }
133+ return null
134+ }
135+
103136/**
104137 * Find an existing install of `pluginName` that came from a marketplace
105138 * *other than* ours. Plugin ids have the shape `<name>@<marketplace>`.
@@ -196,9 +229,13 @@ function listPlugins(): PluginListEntry[] {
196229function ensureMarketplace ( ) : MarketplaceListEntry {
197230 const existing = listMarketplaces ( ) . find ( m => m . name === MARKETPLACE_NAME )
198231 if ( existing ) {
199- logger . log (
200- `Marketplace "${ MARKETPLACE_NAME } " already added (source: ${ existing . source } ).` ,
201- )
232+ // Marketplace already added — but the local snapshot may be stale
233+ // relative to upstream. Pull a fresh copy so we read today's pinned
234+ // set, not whatever was committed when this machine first added the
235+ // marketplace. Cheap (Claude Code downloads a tarball snapshot, no
236+ // git clone) and idempotent.
237+ logger . log ( `Marketplace "${ MARKETPLACE_NAME } " already added; refreshing snapshot…` )
238+ runClaudeCli ( [ 'plugin' , 'marketplace' , 'update' , MARKETPLACE_NAME ] )
202239 return existing
203240 }
204241 logger . log ( `Adding marketplace "${ MARKETPLACE_NAME } " from ${ MARKETPLACE_URL } …` )
@@ -220,6 +257,29 @@ function ensureMarketplace(): MarketplaceListEntry {
220257 return added
221258}
222259
260+ /**
261+ * Load `~/.claude/plugins/installed_plugins.json` — Claude Code's
262+ * authoritative state file for which commit each installed plugin came
263+ * from. Returns `null` if the file is absent or unparseable; the
264+ * reconciler falls back to path-prefix parsing in that case.
265+ */
266+ function loadInstalledPluginsState ( ) : unknown {
267+ const home = process . env [ 'HOME' ] ?? process . env [ 'USERPROFILE' ]
268+ if ( ! home || ! path . isAbsolute ( home ) ) return null
269+ const stateFile = path . join (
270+ home ,
271+ '.claude' ,
272+ 'plugins' ,
273+ 'installed_plugins.json' ,
274+ )
275+ if ( ! existsSync ( stateFile ) ) return null
276+ try {
277+ return JSON . parse ( readFileSync ( stateFile , 'utf8' ) )
278+ } catch {
279+ return null
280+ }
281+ }
282+
223283function loadMarketplaceManifest (
224284 marketplace : MarketplaceListEntry ,
225285) : MarketplaceManifest {
@@ -255,14 +315,30 @@ function installPlugin(installId: string, pinDescription: string): void {
255315 runClaudeCli ( [ 'plugin' , 'install' , installId , '--scope' , 'user' ] )
256316}
257317
318+ /**
319+ * Resolve the installed SHA for a plugin. Prefer the authoritative
320+ * `gitCommitSha` field from `~/.claude/plugins/installed_plugins.json`;
321+ * fall back to parsing the cache dir name for ref-less SHA-prefix
322+ * installs. Returns the full 40-char SHA (or 12-char prefix from the
323+ * fallback path), or `null` if neither source resolves.
324+ */
325+ function resolveInstalledSha (
326+ ours : PluginListEntry ,
327+ state : unknown ,
328+ ) : string | null {
329+ const fromState = lookupInstalledSha ( state , ours . id )
330+ if ( fromState ) return fromState
331+ return extractInstalledSha ( ours . installPath )
332+ }
333+
258334/**
259335 * Reconcile a single plugin to the wheelhouse pin. Handles four cases:
260336 * foreign install (uninstall + install), missing (install), stale SHA
261337 * (uninstall + reinstall), and correct (no-op).
262338 */
263339function reconcilePlugin ( plugin : MarketplacePlugin ) : void {
264340 const ourInstallId = `${ plugin . name } @${ MARKETPLACE_NAME } `
265- const expectedShaPrefix = plugin.source.sha?.slice(0, 12) ?? null
341+ const expectedSha = plugin . source . sha ?? null
266342 const pinDescription =
267343 plugin . source . sha ?? plugin . source . ref ?? '<no ref>'
268344
@@ -281,23 +357,30 @@ function reconcilePlugin(plugin: MarketplacePlugin): void {
281357 plugins = listPlugins ( )
282358 }
283359
284- // (2) Our install present? Check SHA.
360+ // (2) Our install present? Check SHA against installed_plugins.json's
361+ // gitCommitSha field (authoritative) with cache-dir-name parsing as
362+ // fallback. Both SHA forms can compare: the authoritative one is full
363+ // 40-char, the fallback is 12-char prefix, so compare on a shared
364+ // 12-char prefix.
285365 const ours = plugins . find ( p => p . id === ourInstallId )
286366 if ( ours ) {
287- const installedShaPrefix = extractInstalledSha(ours.installPath)
288- if (!expectedShaPrefix) {
367+ if ( ! expectedSha ) {
289368 // Manifest pin has no SHA — we can't drift-compare. Trust the
290369 // existing install.
291370 logger . log ( `Plugin ${ ourInstallId } already installed (manifest has no SHA to compare).` )
292371 return
293372 }
294- if (installedShaPrefix === expectedShaPrefix) {
295- logger.log(` Plugin ${ourInstallId } already installed at pinned SHA ${expectedShaPrefix } . `)
373+ const state = loadInstalledPluginsState ( )
374+ const installedSha = resolveInstalledSha ( ours , state )
375+ const expectedPrefix = expectedSha . slice ( 0 , 12 )
376+ const installedPrefix = installedSha ?. slice ( 0 , 12 ) ?? null
377+ if ( installedPrefix === expectedPrefix ) {
378+ logger . log ( `Plugin ${ ourInstallId } already installed at pinned SHA ${ expectedPrefix } .` )
296379 return
297380 }
298381 // Drift: our install is at a different SHA. Reinstall.
299382 logger . log (
300- ` Plugin ${ourInstallId } drift : installed at ${installedShaPrefix ?? '<unknown>' } , manifest pins ${expectedShaPrefix } . Reinstalling . `,
383+ `Plugin ${ ourInstallId } drift: installed at ${ installedPrefix ?? '<unknown>' } , manifest pins ${ expectedPrefix } . Reinstalling.` ,
301384 )
302385 uninstallPlugin ( ourInstallId )
303386 installPlugin ( ourInstallId , pinDescription )
0 commit comments