33 * @file Reconcile the local machine's Claude Code plugin state to the
44 * wheelhouse-canonical SHA-pinned set.
55 *
6- * - Ensures the `socket-wheelhouse` marketplace is added to Claude
7- * Code (`~/.claude/plugins/known_marketplaces.json`).
8- * - For each plugin in the wheelhouse marketplace's
9- * `.claude-plugin/marketplace.json`, ensures it's installed at the
10- * pinned SHA.
6+ * What the reconciler does:
117 *
12- * Idempotent — running twice is a no-op. Designed for `pnpm setup`
13- * wiring in every fleet repo.
8+ * 1. Ensures the `socket-wheelhouse` marketplace is added to Claude
9+ * Code (`~/.claude/plugins/known_marketplaces.json`).
10+ * 2. For each plugin in the wheelhouse marketplace's
11+ * `.claude-plugin/marketplace.json`:
12+ * - If installed under a *different* marketplace (foreign source) —
13+ * uninstalls it, then installs ours. Wheelhouse is the pin
14+ * authority; foreign installs are silently overriding our pin.
15+ * - If installed under our marketplace at the right SHA — no-op.
16+ * - If installed under our marketplace at a stale SHA — uninstalls
17+ * + reinstalls to bump.
18+ * - If not installed at all — installs.
19+ * 3. Warns (does NOT auto-remove) about marketplaces that exist
20+ * locally + only serve plugins we now serve canonically. The
21+ * user might intentionally keep a dev-source override; let them
22+ * remove it explicitly.
23+ *
24+ * Idempotent — running twice in a row is a no-op. Designed for
25+ * `pnpm setup` wiring in every fleet repo.
1426 *
1527 * Pin discipline is enforced by `.claude/hooks/marketplace-comment-guard/`:
1628 * every `plugins[].source.sha` in `marketplace.json` must have a row
@@ -34,21 +46,26 @@ const logger = getDefaultLogger()
3446const MARKETPLACE_NAME = 'socket-wheelhouse'
3547const MARKETPLACE_URL = 'https://github.com/SocketDev/socket-wheelhouse'
3648
37- interface MarketplaceListEntry {
49+ // Claude Code stores SHA-pinned plugin installs at a cache directory
50+ // whose name is `<sha-12-chars>-<content-hash-8-chars>`. We parse the
51+ // first segment to extract the pinned SHA for drift comparison.
52+ const SHA_PINNED_DIR_NAME = / ^ ( [ 0 - 9 a - f ] { 12 } ) - [ 0 - 9 a - f ] { 8 , } $ /
53+
54+ export interface MarketplaceListEntry {
3855 name: string
3956 source: string
4057 installLocation ? : string
4158}
4259
43- interface PluginListEntry {
60+ export interface PluginListEntry {
4461 id: string
4562 version ? : string
4663 scope ? : string
4764 enabled ? : boolean
4865 installPath ? : string
4966}
5067
51- interface MarketplacePluginSource {
68+ export interface MarketplacePluginSource {
5269 source: string
5370 url ? : string
5471 path ? : string
@@ -57,16 +74,82 @@ interface MarketplacePluginSource {
5774 commit ? : string
5875}
5976
60- interface MarketplacePlugin {
77+ export interface MarketplacePlugin {
6178 name: string
6279 source: MarketplacePluginSource
6380}
6481
65- interface MarketplaceManifest {
82+ export interface MarketplaceManifest {
6683 name ? : string
6784 plugins ?: MarketplacePlugin [ ]
6885}
6986
87+ /**
88+ * Parse the plugin's `installPath` to extract the SHA prefix it was
89+ * pinned to (12 chars). Returns `null` for directory installs,
90+ * 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.
93+ */
94+ export function extractInstalledSha (
95+ installPath : string | undefined ,
96+ ) : string | null {
97+ if ( ! installPath ) return null
98+ const dirName = path . basename ( installPath )
99+ const m = SHA_PINNED_DIR_NAME . exec ( dirName )
100+ return m ? m [ 1 ] ?? null : null
101+ }
102+
103+ /**
104+ * Find an existing install of `pluginName` that came from a marketplace
105+ * *other than* ours. Plugin ids have the shape `<name>@<marketplace>`.
106+ * Returns the foreign install entry, or `undefined` if none.
107+ */
108+ export function findForeignInstall (
109+ pluginName : string ,
110+ plugins : PluginListEntry [ ] ,
111+ ourMarketplace : string ,
112+ ) : PluginListEntry | undefined {
113+ const ourId = `${ pluginName } @${ ourMarketplace } `
114+ for ( const p of plugins ) {
115+ if ( ! p . id . startsWith ( `${ pluginName } @` ) ) continue
116+ if ( p . id === ourId ) continue
117+ return p
118+ }
119+ return undefined
120+ }
121+
122+ /**
123+ * Identify marketplaces that look orphaned — exist locally, aren't
124+ * ours, and only serve plugins our marketplace now serves canonically.
125+ * Returns the marketplace names; we warn the user rather than
126+ * auto-remove (a dev-source override is a legitimate deliberate state).
127+ */
128+ export function findOrphanMarketplaces (
129+ marketplaces : MarketplaceListEntry [ ] ,
130+ ourMarketplace : string ,
131+ ourPluginNames : Set < string > ,
132+ plugins : PluginListEntry [ ] ,
133+ ) : string [ ] {
134+ const orphans : string [ ] = [ ]
135+ for ( const mkt of marketplaces ) {
136+ if ( mkt . name === ourMarketplace ) continue
137+ // Find every plugin installed from this marketplace.
138+ const installedFromHere = plugins
139+ . filter ( p => p . id . endsWith ( `@${ mkt . name } ` ) )
140+ . map ( p => p . id . slice ( 0 , - `@${ mkt . name } ` . length ) )
141+ if ( installedFromHere . length === 0 ) {
142+ // No installs from this marketplace — leave it alone. The user
143+ // added it for a reason we can't see.
144+ continue
145+ }
146+ if ( installedFromHere . every ( name => ourPluginNames . has ( name ) ) ) {
147+ orphans . push ( mkt . name )
148+ }
149+ }
150+ return orphans
151+ }
152+
70153/**
71154 * Run `claude` CLI synchronously; return stdout + exit code. Stderr
72155 * goes through to our own stderr so the user sees CLI errors in real
@@ -162,24 +245,97 @@ function loadMarketplaceManifest(
162245 return JSON . parse ( raw ) as MarketplaceManifest
163246}
164247
165- function ensurePluginInstalled ( plugin : MarketplacePlugin ) : void {
166- const installId = `${plugin . name } @${MARKETPLACE_NAME } `
167- const installed = listPlugins().find(p => p.id === installId)
168- if (installed) {
169- logger.log(` Plugin ${installId } already installed ( scope : ${installed . scope ?? 'unknown' } ) . `)
248+ function uninstallPlugin ( installId : string ) : void {
249+ logger . log ( `Uninstalling ${ installId } …` )
250+ runClaudeCli ( [ 'plugin' , 'uninstall' , installId , '--scope' , 'user' ] )
251+ }
252+
253+ function installPlugin ( installId : string , pinDescription : string ) : void {
254+ logger . log ( `Installing ${ installId } pinned to ${ pinDescription } …` )
255+ runClaudeCli ( [ 'plugin' , 'install' , installId , '--scope' , 'user' ] )
256+ }
257+
258+ /**
259+ * Reconcile a single plugin to the wheelhouse pin. Handles four cases:
260+ * foreign install (uninstall + install), missing (install), stale SHA
261+ * (uninstall + reinstall), and correct (no-op).
262+ */
263+ function reconcilePlugin ( plugin : MarketplacePlugin ) : void {
264+ const ourInstallId = `${plugin . name } @${MARKETPLACE_NAME } `
265+ const expectedShaPrefix = plugin.source.sha?.slice(0, 12) ?? null
266+ const pinDescription =
267+ plugin.source.sha ?? plugin.source.ref ?? '<no ref>'
268+
269+ let plugins = listPlugins()
270+
271+ // (1) Foreign install: same plugin name, different marketplace. Wheelhouse
272+ // is the pin authority; uninstall the foreign install so our pin can
273+ // take effect. The user's enabledPlugins entry under the foreign id
274+ // disappears as a side effect of the CLI uninstall.
275+ const foreign = findForeignInstall(plugin.name, plugins, MARKETPLACE_NAME)
276+ if (foreign) {
277+ logger.log(
278+ ` Found foreign install ${foreign . id } ( path : ${foreign . installPath ?? '<unknown>' } ) ; rewiring to ${ourInstallId } . `,
279+ )
280+ uninstallPlugin(foreign.id)
281+ plugins = listPlugins()
282+ }
283+
284+ // (2) Our install present? Check SHA.
285+ const ours = plugins.find(p => p.id === ourInstallId)
286+ if (ours) {
287+ const installedShaPrefix = extractInstalledSha(ours.installPath)
288+ if (!expectedShaPrefix) {
289+ // Manifest pin has no SHA — we can't drift-compare. Trust the
290+ // existing install.
291+ logger.log(` Plugin ${ourInstallId } already installed ( manifest has no SHA to compare ) . `)
292+ return
293+ }
294+ if (installedShaPrefix === expectedShaPrefix) {
295+ logger.log(` Plugin ${ourInstallId } already installed at pinned SHA ${expectedShaPrefix } . `)
296+ return
297+ }
298+ // Drift: our install is at a different SHA. Reinstall.
299+ logger.log(
300+ ` Plugin ${ourInstallId } drift : installed at ${installedShaPrefix ?? '<unknown>' } , manifest pins ${expectedShaPrefix } . Reinstalling . `,
301+ )
302+ uninstallPlugin(ourInstallId)
303+ installPlugin(ourInstallId, pinDescription)
170304 return
171305 }
172- logger.log(` Installing ${installId } pinned to ${plugin . source . sha ?? plugin . source . ref ?? '<no ref>' } … `)
173- runClaudeCli(['plugin', 'install', installId, '--scope', 'user'])
174- const after = listPlugins().find(p => p.id === installId)
306+
307+ // (3) Not installed at all (or we just uninstalled a foreign copy).
308+ installPlugin(ourInstallId, pinDescription)
309+ const after = listPlugins().find(p => p.id === ourInstallId)
175310 if (!after) {
176311 throw new Error(
177- ` plugin ${installId } did not appear in plugin list after install ` +
312+ ` plugin ${ourInstallId } did not appear in plugin list after install ` +
178313 '— check the CLI output above.',
179314 )
180315 }
181316}
182317
318+ function warnOrphanMarketplaces(
319+ marketplaces: MarketplaceListEntry[],
320+ ourPluginNames: Set<string>,
321+ plugins: PluginListEntry[],
322+ ): void {
323+ const orphans = findOrphanMarketplaces(
324+ marketplaces,
325+ MARKETPLACE_NAME,
326+ ourPluginNames,
327+ plugins,
328+ )
329+ for (const name of orphans) {
330+ logger.warn(
331+ ` Marketplace "${name } " appears to only serve plugins we now pin via ` +
332+ ` "${MARKETPLACE_NAME } ". Consider \`claude plugin marketplace remove ${ name } \` ` +
333+ `to keep your config tidy. (Not auto-removed — a deliberate dev-source ` +
334+ `override is a legitimate state we won't silently undo.)` ,
335+ )
336+ }
337+ }
338+
183339function main ( ) : void {
184340 logger. log ( `Reconciling Claude Code plugins to ${ MARKETPLACE_NAME } …` )
185341 const marketplace = ensureMarketplace ( )
@@ -191,14 +347,23 @@ function main(): void {
191347 )
192348 }
193349 for ( const plugin of plugins ) {
194- ensurePluginInstalled ( plugin )
350+ reconcilePlugin ( plugin )
195351 }
352+
353+ // Post-pass: warn about marketplaces that now look redundant.
354+ const ourPluginNames = new Set ( plugins . map ( p => p . name ) )
355+ warnOrphanMarketplaces ( listMarketplaces ( ) , ourPluginNames , listPlugins ( ) )
356+
196357 logger . log ( 'Done . ')
197358}
198359
199- try {
200- main ( )
201- } catch ( e ) {
202- logger . fail ( errorMessage ( e ) )
203- process . exit ( 1 )
360+ // Skip execution when imported (for tests). The CLI entry is direct
361+ // `node scripts/install-claude-plugins.mts` invocation.
362+ if ( import . meta. url === `file :/ / ${process . argv [ 1 ] } `) {
363+ try {
364+ main ( )
365+ } catch ( e ) {
366+ logger . fail ( errorMessage ( e ) )
367+ process . exit ( 1 )
368+ }
204369}
0 commit comments