Skip to content

Commit 25fc322

Browse files
fix: install to versioned cache path matching Claude Code's plugin system
The Python installer was copying files to ~/.claude/plugins/codebase-wizard/ but Claude Code loads plugins from the versioned cache directory: ~/.claude/plugins/cache/{marketplace}/{plugin}/{version}/ Changes: - _resolve_dest() now returns the cache path for global installs - install() reads version from source, cleans stale version dirs (update path) - uninstall() removes the entire plugin/cache dir (all versions) - status() checks the cache path for installed versions - _register_installed_plugin() now receives and stores gitCommitSha - Added _git_sha() helper to capture current HEAD SHA - Added test_update_replaces_old_version to cover the update scenario - Added PLUGIN_NAME constant; updated _register_plugin() signature Result: ai-codebase-mentor install --for claude now writes the same registry entries and installPath as Claude Code's /plugin UI install. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 99f698f commit 25fc322

2 files changed

Lines changed: 158 additions & 52 deletions

File tree

ai_codebase_mentor/converters/claude.py

Lines changed: 113 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
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
44
registers 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+
912
Install destinations:
10-
Global: ~/.claude/plugins/codebase-wizard/
13+
Global: ~/.claude/plugins/cache/codebase-mentor/codebase-wizard/{version}/
1114
Project: ./plugins/codebase-wizard/
1215
"""
1316

1417
import json
1518
import shutil
19+
import subprocess
1620
import sys
1721
from datetime import datetime, timezone
1822
from pathlib import Path
@@ -22,32 +26,53 @@
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"
2530
PLUGIN_REGISTRY_KEY = "codebase-wizard@codebase-mentor"
2631
MARKETPLACE_ID = "codebase-mentor"
2732
MARKETPLACE_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+
3050
class 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:

tests/test_claude_installer.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,25 @@
66

77
import pytest
88

9-
from ai_codebase_mentor.converters.claude import ClaudeInstaller, PLUGIN_REGISTRY_KEY
9+
from ai_codebase_mentor.converters.claude import (
10+
ClaudeInstaller,
11+
MARKETPLACE_ID,
12+
PLUGIN_NAME,
13+
PLUGIN_REGISTRY_KEY,
14+
)
1015

1116
# Path to the real plugin source (relative to project root)
1217
REAL_PLUGIN_SOURCE = Path(__file__).parent.parent / "plugins" / "codebase-wizard"
1318

1419

20+
def _global_cache_path(tmp_path: Path, version: str = "1.0.0") -> Path:
21+
"""Return the expected global install cache path for tests."""
22+
return (
23+
tmp_path / "home" / ".claude" / "plugins" / "cache"
24+
/ MARKETPLACE_ID / PLUGIN_NAME / version
25+
)
26+
27+
1528
@pytest.fixture
1629
def source_plugin_dir(tmp_path):
1730
"""Copy the real plugin source to a temp dir so tests don't touch the repo."""
@@ -33,12 +46,9 @@ def installer(tmp_path, monkeypatch):
3346

3447

3548
def test_global_install_copies_plugin_json(installer, source_plugin_dir, tmp_path):
36-
"""install(source, 'global') copies plugin.json to ~/.claude/plugins/codebase-wizard/."""
49+
"""install(source, 'global') copies plugin.json to the versioned cache directory."""
3750
installer.install(source_plugin_dir, "global")
38-
dest_manifest = (
39-
tmp_path / "home" / ".claude" / "plugins" / "codebase-wizard"
40-
/ ".claude-plugin" / "plugin.json"
41-
)
51+
dest_manifest = _global_cache_path(tmp_path) / ".claude-plugin" / "plugin.json"
4252
assert dest_manifest.exists(), f"plugin.json not found at {dest_manifest}"
4353

4454

@@ -59,11 +69,16 @@ def test_global_install_is_idempotent(installer, source_plugin_dir):
5969

6070

6171
def test_global_uninstall_removes_directory(installer, source_plugin_dir, tmp_path):
62-
"""uninstall('global') removes ~/.claude/plugins/codebase-wizard/ entirely."""
72+
"""uninstall('global') removes the entire plugin directory from the cache."""
6373
installer.install(source_plugin_dir, "global")
6474
installer.uninstall("global")
65-
dest = tmp_path / "home" / ".claude" / "plugins" / "codebase-wizard"
66-
assert not dest.exists(), f"Expected directory to be removed: {dest}"
75+
cache_plugin_dir = (
76+
tmp_path / "home" / ".claude" / "plugins" / "cache"
77+
/ MARKETPLACE_ID / PLUGIN_NAME
78+
)
79+
assert not cache_plugin_dir.exists(), (
80+
f"Expected cache plugin directory to be removed: {cache_plugin_dir}"
81+
)
6782

6883

6984
def test_project_uninstall_removes_directory(installer, source_plugin_dir, tmp_path):
@@ -80,10 +95,10 @@ def test_uninstall_when_not_installed_is_noop(installer):
8095

8196

8297
def test_status_after_global_install(installer, source_plugin_dir, tmp_path):
83-
"""status() returns installed=True with correct location and version after global install."""
98+
"""status() returns installed=True with the cache path and version after global install."""
8499
installer.install(source_plugin_dir, "global")
85100
result = installer.status()
86-
expected_location = str(tmp_path / "home" / ".claude" / "plugins" / "codebase-wizard")
101+
expected_location = str(_global_cache_path(tmp_path))
87102
assert result["installed"] is True
88103
assert result["location"] == expected_location
89104
assert result["version"] == "1.0.0"
@@ -112,10 +127,12 @@ def test_install_registers_in_installed_plugins_json(installer, source_plugin_di
112127
)
113128
entry = data["plugins"][PLUGIN_REGISTRY_KEY][0]
114129
assert entry["scope"] == "user"
115-
assert "installPath" in entry
130+
assert "cache" in entry["installPath"], "installPath should use cache directory"
131+
assert MARKETPLACE_ID in entry["installPath"], "installPath should include marketplace ID"
116132
assert entry["version"] == "1.0.0"
117133
assert "installedAt" in entry
118134
assert "lastUpdated" in entry
135+
assert "gitCommitSha" in entry
119136

120137

121138
def test_install_enables_plugin_in_settings_json(installer, source_plugin_dir, tmp_path):
@@ -157,3 +174,19 @@ def test_uninstall_removes_registration(installer, source_plugin_dir, tmp_path):
157174
assert PLUGIN_REGISTRY_KEY not in data.get("enabledPlugins", {}), (
158175
f"'{PLUGIN_REGISTRY_KEY}' still in settings.json enabledPlugins after uninstall"
159176
)
177+
178+
179+
def test_update_replaces_old_version(installer, source_plugin_dir, tmp_path):
180+
"""Installing a new version removes the old version directory from cache."""
181+
# Simulate an older version already installed
182+
old_version_dir = _global_cache_path(tmp_path, "0.9.0")
183+
old_version_dir.mkdir(parents=True)
184+
(old_version_dir / "old-marker.txt").touch()
185+
186+
installer.install(source_plugin_dir, "global")
187+
188+
# Old version dir should be gone
189+
assert not old_version_dir.exists(), "Old version directory should be removed on update"
190+
# New version should be installed
191+
new_version_dir = _global_cache_path(tmp_path, "1.0.0")
192+
assert new_version_dir.exists(), "New version directory should exist after install"

0 commit comments

Comments
 (0)