Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "token-saver",
"source": "./",
"description": "Automatically compresses verbose CLI output to save tokens. Supports git, docker, npm, terraform, kubectl, and 13+ other command families.",
"version": "2.0.0",
"version": "2.0.1",
"author": {
"name": "ppgranger"
},
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "token-saver",
"description": "Automatically compresses verbose CLI output (git, docker, npm, terraform, kubectl, etc.) to save tokens in Claude Code sessions. Supports 18+ command families with smart compression.",
"version": "2.0.0",
"version": "2.0.1",
"author": {
"name": "ppgranger",
"url": "https://github.com/ppgranger"
Expand Down
62 changes: 42 additions & 20 deletions installers/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ def _plugin_cache_dir(version):
)


def _marketplace_dir():
"""Return the marketplace directory for token-saver.

Claude Code reads .claude-plugin/marketplace.json from this path to
discover available plugins. This mirrors the layout produced by
``/plugin marketplace add``.
"""
return os.path.join(_settings_dir(), "plugins", "marketplaces", _MARKETPLACE_NAME)


def _settings_path():
"""Return path to Claude Code settings.json."""
return os.path.join(_settings_dir(), "settings.json")
Expand Down Expand Up @@ -110,12 +120,14 @@ def _iso_now():
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")


def _register_plugin(target_dir, version):
def _register_plugin(marketplace_dir, cache_dir, version):
"""Register token-saver as a native Claude Code plugin.

Registers the GitHub repo as a known marketplace, writes the plugin
entry in installed_plugins.json (v2 format), and enables it in
settings.json.
Registers the GitHub repo as a known marketplace (pointing
``installLocation`` at *marketplace_dir* so Claude Code can find
``.claude-plugin/marketplace.json``), writes the plugin entry in
installed_plugins.json (v2 format, ``installPath`` → *cache_dir*),
and enables it in settings.json.
"""
plugins_dir = os.path.join(_settings_dir(), "plugins")
os.makedirs(plugins_dir, exist_ok=True)
Expand All @@ -135,8 +147,9 @@ def _register_plugin(target_dir, version):
"source": {
"source": "github",
"repo": _GITHUB_REPO,
"ref": "production",
},
"installLocation": target_dir,
"installLocation": marketplace_dir,
"lastUpdated": now,
}

Expand All @@ -161,7 +174,7 @@ def _register_plugin(target_dir, version):
registry["plugins"][_PLUGIN_KEY] = [
{
"scope": "user",
"installPath": target_dir,
"installPath": cache_dir,
"version": version,
"installedAt": now,
"lastUpdated": now,
Expand Down Expand Up @@ -371,21 +384,25 @@ def install(use_symlink=False):

# 2. Install files to the versioned plugin cache directory
version = _read_version()
target_dir = _plugin_cache_dir(version)
print(f"\n--- Claude Code ({target_dir}) ---")
install_files(target_dir, CLAUDE_FILES, use_symlink)

# 3. Stamp version in BOTH plugin.json and marketplace.json
stamp_version(
target_dir,
[
".claude-plugin/plugin.json",
".claude-plugin/marketplace.json",
],
)
cache_dir = _plugin_cache_dir(version)
print(f"\n--- Claude Code (cache: {cache_dir}) ---")
install_files(cache_dir, CLAUDE_FILES, use_symlink)

# 3. Install files to the marketplace directory (for plugin discovery)
mkt_dir = _marketplace_dir()
print(f"--- Claude Code (marketplace: {mkt_dir}) ---")
install_files(mkt_dir, CLAUDE_FILES, use_symlink)

# 4. Stamp version in BOTH plugin.json and marketplace.json (in both dirs)
version_files = [
".claude-plugin/plugin.json",
".claude-plugin/marketplace.json",
]
stamp_version(cache_dir, version_files)
stamp_version(mkt_dir, version_files)

# 4. Register marketplace + plugin
_register_plugin(target_dir, version)
# 5. Register marketplace + plugin (marketplace dir for discovery, cache for runtime)
_register_plugin(mkt_dir, cache_dir, version)

print(" Plugin registered. Restart Claude Code, then /plugin to manage.")

Expand All @@ -405,6 +422,11 @@ def uninstall():
if os.path.isdir(cache_root):
uninstall_dir(cache_root)

# Remove marketplace discovery directory
mkt_dir = _marketplace_dir()
if os.path.isdir(mkt_dir):
uninstall_dir(mkt_dir)

# Also remove old v1 location if it still exists
old_dir = _plugin_dir()
if os.path.isdir(old_dir):
Expand Down
2 changes: 1 addition & 1 deletion src/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os

__version__ = "2.0.0"
__version__ = "2.0.1"


def data_dir() -> str:
Expand Down
21 changes: 12 additions & 9 deletions tests/test_installers.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,11 +504,13 @@ class TestRegisterPlugin:

def setup_method(self):
self.tmp_home = tempfile.mkdtemp()
self.tmp_target = tempfile.mkdtemp()
self.tmp_target = tempfile.mkdtemp() # cache dir (plugin runtime)
self.tmp_marketplace = tempfile.mkdtemp() # marketplace dir (discovery)

def teardown_method(self):
shutil.rmtree(self.tmp_home, ignore_errors=True)
shutil.rmtree(self.tmp_target, ignore_errors=True)
shutil.rmtree(self.tmp_marketplace, ignore_errors=True)

def _settings_dir(self):
return os.path.join(self.tmp_home, ".claude")
Expand All @@ -534,21 +536,22 @@ def test_registers_marketplace(self):
from installers.claude import _register_plugin

with mock.patch("installers.claude.home", return_value=self.tmp_home):
_register_plugin(self.tmp_target, "2.0.0")
_register_plugin(self.tmp_marketplace, self.tmp_target, "2.0.0")

with open(self._known_marketplaces_path()) as f:
known = json.load(f)
assert "token-saver-marketplace" in known
entry = known["token-saver-marketplace"]
assert entry["source"]["source"] == "github"
assert entry["source"]["repo"] == "ppgranger/token-saver"
assert entry["installLocation"] == self.tmp_target
assert entry["source"]["ref"] == "production"
assert entry["installLocation"] == self.tmp_marketplace

def test_registers_in_installed_plugins_v2_format(self):
from installers.claude import _register_plugin

with mock.patch("installers.claude.home", return_value=self.tmp_home):
_register_plugin(self.tmp_target, "2.0.0")
_register_plugin(self.tmp_marketplace, self.tmp_target, "2.0.0")

with open(self._installed_plugins_path()) as f:
data = json.load(f)
Expand All @@ -565,7 +568,7 @@ def test_enables_in_settings(self):
from installers.claude import _register_plugin

with mock.patch("installers.claude.home", return_value=self.tmp_home):
_register_plugin(self.tmp_target, "2.0.0")
_register_plugin(self.tmp_marketplace, self.tmp_target, "2.0.0")

with open(self._settings_path()) as f:
settings = json.load(f)
Expand All @@ -576,8 +579,8 @@ def test_no_duplicates_on_reregistration(self):
from installers.claude import _register_plugin

with mock.patch("installers.claude.home", return_value=self.tmp_home):
_register_plugin(self.tmp_target, "2.0.0")
_register_plugin(self.tmp_target, "2.0.0")
_register_plugin(self.tmp_marketplace, self.tmp_target, "2.0.0")
_register_plugin(self.tmp_marketplace, self.tmp_target, "2.0.0")

with open(self._installed_plugins_path()) as f:
data = json.load(f)
Expand All @@ -602,7 +605,7 @@ def test_preserves_existing_marketplaces(self):
)

with mock.patch("installers.claude.home", return_value=self.tmp_home):
_register_plugin(self.tmp_target, "2.0.0")
_register_plugin(self.tmp_marketplace, self.tmp_target, "2.0.0")

with open(km_path) as f:
known = json.load(f)
Expand All @@ -627,7 +630,7 @@ def test_preserves_existing_v2_plugins(self):
)

with mock.patch("installers.claude.home", return_value=self.tmp_home):
_register_plugin(self.tmp_target, "2.0.0")
_register_plugin(self.tmp_marketplace, self.tmp_target, "2.0.0")

with open(plugins_path) as f:
data = json.load(f)
Expand Down