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
118 changes: 118 additions & 0 deletions packages/claude-code-plugin/hooks/lib/updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Marketplace clone auto-updater for CodingBuddy plugin (#1101).

Workaround for Claude Code /plugin update not pulling the marketplace
shallow clone (anthropics/claude-code#40214).
"""
import json
import os
import subprocess
import time
from pathlib import Path
from typing import Optional

THROTTLE_SECONDS = 86400 # 24 hours
GIT_TIMEOUT = 5 # seconds
MARKETPLACE_NAME = "jeremydev87"
PLUGIN_PKG_PATH = "packages/claude-code-plugin/package.json"


def find_marketplace_clone(home: Path) -> Optional[Path]:
"""Find the marketplace clone directory.

Returns:
Path to the marketplace clone, or None if not found or not a git repo.
"""
clone_dir = home / ".claude" / "plugins" / "marketplaces" / MARKETPLACE_NAME
if clone_dir.is_dir() and (clone_dir / ".git").exists():
return clone_dir
return None


def should_check_update(timestamp_file: Path) -> bool:
"""Check if enough time has passed since the last update check."""
try:
last_check = float(timestamp_file.read_text().strip())
return (time.time() - last_check) >= THROTTLE_SECONDS
except (FileNotFoundError, ValueError, OSError):
return True


def record_update_check(timestamp_file: Path) -> None:
"""Record the current time as the last update check."""
timestamp_file.parent.mkdir(parents=True, exist_ok=True)
timestamp_file.write_text(str(time.time()))


def _git(clone_dir: Path, *args: str) -> subprocess.CompletedProcess:
"""Run a git command with timeout. Returns a failed result on timeout."""
try:
return subprocess.run(
["git", "-C", str(clone_dir), *args],
capture_output=True,
text=True,
timeout=GIT_TIMEOUT,
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(
args=["git", *args], returncode=1, stdout="", stderr="timeout"
)


def auto_update_marketplace(
home: Optional[Path] = None,
) -> Optional[str]:
"""Auto-update the marketplace clone if a newer version is available.

Args:
home: Home directory (default: Path.home()).

Returns:
New version string if updated, None otherwise.
"""
if home is None:
home = Path.home()

clone_dir = find_marketplace_clone(home)
if clone_dir is None:
return None

data_dir = home / ".codingbuddy"
ts_file = data_dir / ".last_update_check"

if not should_check_update(ts_file):
return None

# Fetch latest from remote
result = _git(clone_dir, "fetch", "origin")
if result.returncode != 0:
return None # Network unavailable — silent fail

# Compare HEAD with remote
head = _git(clone_dir, "rev-parse", "HEAD")
remote = _git(clone_dir, "rev-parse", "origin/master")

if head.returncode != 0 or remote.returncode != 0:
return None

if head.stdout.strip() == remote.stdout.strip():
record_update_check(ts_file)
return None # Already up to date

# Update: unshallow if needed, then reset
_git(clone_dir, "fetch", "--unshallow", "origin") # May fail if already full — OK
reset = _git(clone_dir, "reset", "--hard", "origin/master")
if reset.returncode != 0:
return None

record_update_check(ts_file)

# Read new version from package.json
try:
pkg_path = clone_dir / PLUGIN_PKG_PATH
if pkg_path.exists():
pkg = json.loads(pkg_path.read_text())
return pkg.get("version")
except (json.JSONDecodeError, OSError):
pass

return "unknown"
17 changes: 17 additions & 0 deletions packages/claude-code-plugin/hooks/session-start.py
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,15 @@ def main():

installed_hook = False
registered_settings = False
new_version = None

# Step 0.5: Auto-update marketplace clone (#1101)
try:
_ensure_lib_path()
from updater import auto_update_marketplace
new_version = auto_update_marketplace(home=home)
except Exception:
pass # Never block session start

# Step 1: Install hook file if not exists
if not target_file.exists():
Expand Down Expand Up @@ -883,6 +892,14 @@ def main():
)
if output:
print(output)

# Show update notification if marketplace clone was updated (#1101)
if new_version:
print(
f"\n🔄 CodingBuddy v{new_version} available in marketplace!"
f"\n → Run /plugin to update\n",
file=sys.stderr,
)
except Exception:
pass # Never block session start

Expand Down
138 changes: 138 additions & 0 deletions packages/claude-code-plugin/tests/test_updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Tests for marketplace auto-updater (#1101)."""
import json
import os
import subprocess
import tempfile
import time
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest

import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "hooks" / "lib"))
from updater import (
find_marketplace_clone,
should_check_update,
record_update_check,
auto_update_marketplace,
THROTTLE_SECONDS,
)


class TestFindMarketplaceClone:
def test_finds_jeremydev87_marketplace(self):
with tempfile.TemporaryDirectory() as tmpdir:
clone_dir = Path(tmpdir) / ".claude" / "plugins" / "marketplaces" / "jeremydev87"
clone_dir.mkdir(parents=True)
(clone_dir / ".git").mkdir()

result = find_marketplace_clone(Path(tmpdir))
assert result == clone_dir

def test_returns_none_when_no_marketplace(self):
with tempfile.TemporaryDirectory() as tmpdir:
result = find_marketplace_clone(Path(tmpdir))
assert result is None

def test_returns_none_when_not_git_repo(self):
with tempfile.TemporaryDirectory() as tmpdir:
clone_dir = Path(tmpdir) / ".claude" / "plugins" / "marketplaces" / "jeremydev87"
clone_dir.mkdir(parents=True)
# No .git directory

result = find_marketplace_clone(Path(tmpdir))
assert result is None


class TestThrottle:
def test_should_check_when_no_timestamp_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
assert should_check_update(Path(tmpdir) / "nonexistent") is True

def test_should_not_check_within_throttle(self):
with tempfile.TemporaryDirectory() as tmpdir:
ts_file = Path(tmpdir) / ".last_update_check"
ts_file.write_text(str(time.time()))

assert should_check_update(ts_file) is False

def test_should_check_after_throttle_expired(self):
with tempfile.TemporaryDirectory() as tmpdir:
ts_file = Path(tmpdir) / ".last_update_check"
ts_file.write_text(str(time.time() - THROTTLE_SECONDS - 1))

assert should_check_update(ts_file) is True

def test_record_creates_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
ts_file = Path(tmpdir) / "sub" / ".last_update_check"
record_update_check(ts_file)
assert ts_file.exists()
assert abs(float(ts_file.read_text()) - time.time()) < 2


class TestAutoUpdate:
def test_returns_none_when_no_marketplace(self):
with tempfile.TemporaryDirectory() as tmpdir:
result = auto_update_marketplace(home=Path(tmpdir))
assert result is None

@patch("updater.subprocess.run")
def test_returns_none_when_throttled(self, mock_run):
with tempfile.TemporaryDirectory() as tmpdir:
home = Path(tmpdir)
clone_dir = home / ".claude" / "plugins" / "marketplaces" / "jeremydev87"
clone_dir.mkdir(parents=True)
(clone_dir / ".git").mkdir()

# Write recent timestamp
data_dir = home / ".codingbuddy"
data_dir.mkdir(parents=True)
(data_dir / ".last_update_check").write_text(str(time.time()))

result = auto_update_marketplace(home=home)
assert result is None
mock_run.assert_not_called()

@patch("updater.subprocess.run")
def test_returns_none_when_up_to_date(self, mock_run):
with tempfile.TemporaryDirectory() as tmpdir:
home = Path(tmpdir)
clone_dir = home / ".claude" / "plugins" / "marketplaces" / "jeremydev87"
clone_dir.mkdir(parents=True)
(clone_dir / ".git").mkdir()

# Mock git commands: fetch succeeds, HEAD == origin/master
mock_run.side_effect = [
MagicMock(returncode=0), # git fetch
MagicMock(returncode=0, stdout="abc123\n"), # git rev-parse HEAD
MagicMock(returncode=0, stdout="abc123\n"), # git rev-parse origin/master
]

result = auto_update_marketplace(home=home)
assert result is None

@patch("updater.subprocess.run")
def test_returns_version_when_updated(self, mock_run):
with tempfile.TemporaryDirectory() as tmpdir:
home = Path(tmpdir)
clone_dir = home / ".claude" / "plugins" / "marketplaces" / "jeremydev87"
clone_dir.mkdir(parents=True)
(clone_dir / ".git").mkdir()

# Create package.json with version
pkg = clone_dir / "packages" / "claude-code-plugin" / "package.json"
pkg.parent.mkdir(parents=True)
pkg.write_text(json.dumps({"version": "5.2.0"}))

mock_run.side_effect = [
MagicMock(returncode=0), # git fetch
MagicMock(returncode=0, stdout="abc123\n"), # git rev-parse HEAD
MagicMock(returncode=0, stdout="def456\n"), # git rev-parse origin/master
MagicMock(returncode=1), # git fetch --unshallow (fails = already full)
MagicMock(returncode=0), # git reset --hard origin/master
]

result = auto_update_marketplace(home=home)
assert result == "5.2.0"
Loading