11"""Claude Code runtime installer for ai-codebase-mentor.
22
3- Installs the Codebase Wizard plugin to the Claude Code plugin directories and
3+ Installs the Codebase Wizard plugin to the Claude Code plugin cache and
44registers it in all three Claude Code plugin registry files:
55 - known_marketplaces.json (registers "codebase-mentor" as a git marketplace)
66 - installed_plugins.json (registers the plugin under "codebase-wizard@codebase-mentor")
77 - settings.json (enables the plugin via enabledPlugins)
88
9+ Claude Code loads plugins from a versioned cache directory:
10+ ~/.claude/plugins/cache/{marketplace}/{plugin}/{version}/
11+
912Install destinations:
10- Global: ~/.claude/plugins/codebase-wizard/
13+ Global: ~/.claude/plugins/cache/ codebase-mentor/codebase- wizard/{version} /
1114 Project: ./plugins/codebase-wizard/
1215"""
1316
1417import json
1518import shutil
19+ import subprocess
1620import sys
1721from datetime import datetime , timezone
1822from pathlib import Path
2226
2327# Registry key used in installed_plugins.json and settings.json.
2428# Format: "pluginname@marketplaceid" where marketplaceid is the key in known_marketplaces.json.
29+ PLUGIN_NAME = "codebase-wizard"
2530PLUGIN_REGISTRY_KEY = "codebase-wizard@codebase-mentor"
2631MARKETPLACE_ID = "codebase-mentor"
2732MARKETPLACE_GIT_URL = "https://github.com/SpillwaveSolutions/codebase-mentor.git"
2833
2934
35+ def _git_sha () -> str :
36+ """Return current HEAD commit SHA, or empty string if not in a git repo."""
37+ try :
38+ result = subprocess .run (
39+ ["git" , "rev-parse" , "HEAD" ],
40+ capture_output = True ,
41+ text = True ,
42+ timeout = 5 ,
43+ check = False ,
44+ )
45+ return result .stdout .strip () if result .returncode == 0 else ""
46+ except Exception :
47+ return ""
48+
49+
3050class ClaudeInstaller (RuntimeInstaller ):
3151 """Installs the Codebase Wizard plugin for Claude Code.
3252
33- Copies the plugin tree and registers it in all three Claude Code registry
34- files so the plugin is discoverable without a marketplace server.
53+ Copies the plugin tree to the versioned Claude Code cache directory and
54+ registers it in all three Claude Code registry files so the plugin loads
55+ on next session start.
3556
36- Supports global install (~/.claude/plugins/) and per-project install
37- (./plugins/). Both are idempotent — calling install twice updates in place.
57+ Supports global install (~/.claude/plugins/cache/.../version/) and
58+ per-project install (./plugins/codebase-wizard/). Both are idempotent —
59+ calling install again replaces the current version in place.
3860 """
3961
4062 def install (self , source : Path , target : str = "global" ) -> None :
41- """Copy the plugin tree and register it with Claude Code.
63+ """Copy the plugin tree to the Claude Code cache and register it .
4264
4365 Writes three registry files so Claude Code loads the plugin:
4466 1. known_marketplaces.json — registers "codebase-mentor" git marketplace
4567 2. installed_plugins.json — registers codebase-wizard@codebase-mentor
4668 3. settings.json — enables the plugin
4769
70+ On update: removes old version directories from the cache before
71+ installing the new version (avoids version directory accumulation).
72+
4873 Args:
4974 source: Path to the bundled plugin directory (contains .claude-plugin/).
50- target: "global" → ~/.claude/plugins/codebase-wizard/
75+ target: "global" → ~/.claude/plugins/cache/ codebase-mentor/codebase- wizard/{version} /
5176 "project" → ./plugins/codebase-wizard/
5277
5378 Raises:
@@ -56,55 +81,93 @@ def install(self, source: Path, target: str = "global") -> None:
5681 if not source .exists ():
5782 raise RuntimeError (f"Plugin source directory not found: { source } " )
5883
59- destination = self ._resolve_dest (target )
84+ version = _read_version (source ) or "1.0.0"
85+ destination = self ._resolve_dest (target , version )
86+
87+ # Remove stale version directories before installing (handles updates).
88+ if target == "global" :
89+ cache_plugin_dir = destination .parent
90+ if cache_plugin_dir .exists ():
91+ for stale in cache_plugin_dir .iterdir ():
92+ if stale .is_dir () and stale != destination :
93+ shutil .rmtree (stale , ignore_errors = True )
6094
6195 try :
6296 if destination .exists ():
6397 shutil .rmtree (destination )
98+ destination .parent .mkdir (parents = True , exist_ok = True )
6499 shutil .copytree (source , destination )
65100 except OSError as e :
66101 raise RuntimeError (f"Failed to install to { destination } : { e } " ) from e
67102
68103 if target == "global" :
69- version = _read_version (destination ) or "1.0.0"
70- self ._register_plugin (destination , version )
104+ self ._register_plugin (destination , version , _git_sha ())
71105
72106 def uninstall (self , target : str = "global" ) -> None :
73- """Remove the installed plugin directory and unregister it.
107+ """Remove the installed plugin and unregister it.
108+
109+ For global installs, removes the entire plugin directory from the
110+ cache (all versions) and cleans up all three registry files.
74111
75112 Args:
76113 target: "global" or "project" — same scope as install().
77114
78115 Never raises. No-op if the plugin is not installed.
79116 """
80- destination = self ._resolve_dest (target )
81- if not destination .exists ():
82- return
83- try :
84- shutil .rmtree (destination )
85- except OSError as e :
86- raise RuntimeError (f"Failed to uninstall from { destination } : { e } " ) from e
87-
88117 if target == "global" :
118+ cache_plugin_dir = (
119+ Path .home ()
120+ / ".claude"
121+ / "plugins"
122+ / "cache"
123+ / MARKETPLACE_ID
124+ / PLUGIN_NAME
125+ )
126+ if cache_plugin_dir .exists ():
127+ try :
128+ shutil .rmtree (cache_plugin_dir )
129+ except OSError as e :
130+ raise RuntimeError (
131+ f"Failed to uninstall from { cache_plugin_dir } : { e } "
132+ ) from e
89133 self ._unregister_plugin ()
134+ else :
135+ destination = Path .cwd () / "plugins" / PLUGIN_NAME
136+ if not destination .exists ():
137+ return
138+ try :
139+ shutil .rmtree (destination )
140+ except OSError as e :
141+ raise RuntimeError (f"Failed to uninstall from { destination } : { e } " ) from e
90142
91143 def status (self ) -> dict :
92144 """Report current install state for both global and project installs.
93145
94- Checks global location first, then project. Returns the first found.
146+ For global installs, checks the cache directory for any installed version.
147+ Returns the first found.
95148
96149 Returns:
97150 {"installed": bool, "location": str | None, "version": str | None}
98151 """
99- global_dest = Path .home () / ".claude" / "plugins" / "codebase-wizard"
100- if global_dest .exists ():
101- return {
102- "installed" : True ,
103- "location" : str (global_dest ),
104- "version" : _read_version (global_dest ),
105- }
106-
107- project_dest = Path .cwd () / "plugins" / "codebase-wizard"
152+ cache_plugin_dir = (
153+ Path .home ()
154+ / ".claude"
155+ / "plugins"
156+ / "cache"
157+ / MARKETPLACE_ID
158+ / PLUGIN_NAME
159+ )
160+ if cache_plugin_dir .exists ():
161+ versions = sorted (d for d in cache_plugin_dir .iterdir () if d .is_dir ())
162+ if versions :
163+ latest = versions [- 1 ]
164+ return {
165+ "installed" : True ,
166+ "location" : str (latest ),
167+ "version" : _read_version (latest ),
168+ }
169+
170+ project_dest = Path .cwd () / "plugins" / PLUGIN_NAME
108171 if project_dest .exists ():
109172 return {
110173 "installed" : True ,
@@ -114,20 +177,30 @@ def status(self) -> dict:
114177
115178 return {"installed" : False , "location" : None , "version" : None }
116179
117- def _resolve_dest (self , target : str ) -> Path :
118- """Resolve the install destination based on target scope."""
180+ def _resolve_dest (self , target : str , version : str = "1.0.0" ) -> Path :
181+ """Resolve the install destination based on target scope and version ."""
119182 if target == "global" :
120- return Path .home () / ".claude" / "plugins" / "codebase-wizard"
121- return Path .cwd () / "plugins" / "codebase-wizard"
183+ return (
184+ Path .home ()
185+ / ".claude"
186+ / "plugins"
187+ / "cache"
188+ / MARKETPLACE_ID
189+ / PLUGIN_NAME
190+ / version
191+ )
192+ return Path .cwd () / "plugins" / PLUGIN_NAME
122193
123194 # ------------------------------------------------------------------ #
124195 # Registry helpers #
125196 # ------------------------------------------------------------------ #
126197
127- def _register_plugin (self , install_path : Path , version : str ) -> None :
198+ def _register_plugin (
199+ self , install_path : Path , version : str , git_sha : str = ""
200+ ) -> None :
128201 """Register the plugin in all three Claude Code registry files."""
129202 self ._register_marketplace ()
130- self ._register_installed_plugin (install_path , version )
203+ self ._register_installed_plugin (install_path , version , git_sha )
131204 self ._enable_plugin ()
132205
133206 def _register_marketplace (self ) -> None :
@@ -156,7 +229,9 @@ def _register_marketplace(self) -> None:
156229 path .parent .mkdir (parents = True , exist_ok = True )
157230 path .write_text (json .dumps (data , indent = 4 ) + "\n " , encoding = "utf-8" )
158231
159- def _register_installed_plugin (self , install_path : Path , version : str ) -> None :
232+ def _register_installed_plugin (
233+ self , install_path : Path , version : str , git_sha : str = ""
234+ ) -> None :
160235 """Write or update the plugin entry in installed_plugins.json."""
161236 path = Path .home () / ".claude" / "plugins" / "installed_plugins.json"
162237 data : dict = {"version" : 2 , "plugins" : {}}
@@ -184,7 +259,7 @@ def _register_installed_plugin(self, install_path: Path, version: str) -> None:
184259 "version" : version ,
185260 "installedAt" : installed_at ,
186261 "lastUpdated" : now ,
187- "gitCommitSha" : "" ,
262+ "gitCommitSha" : git_sha ,
188263 }
189264 ]
190265 path .parent .mkdir (parents = True , exist_ok = True )
@@ -212,7 +287,6 @@ def _enable_plugin(self) -> None:
212287
213288 def _unregister_plugin (self ) -> None :
214289 """Remove the plugin entry from installed_plugins.json and settings.json."""
215- # Remove from installed_plugins.json
216290 installed_path = Path .home () / ".claude" / "plugins" / "installed_plugins.json"
217291 if installed_path .exists ():
218292 try :
@@ -224,7 +298,6 @@ def _unregister_plugin(self) -> None:
224298 except (json .JSONDecodeError , OSError ):
225299 pass
226300
227- # Remove from settings.json enabledPlugins
228301 settings_path = Path .home () / ".claude" / "settings.json"
229302 if settings_path .exists ():
230303 try :
0 commit comments