Skip to content

Commit 4abc229

Browse files
committed
feat(plugin): auto-update marketplace clone in session-start hook
Workaround for Claude Code /plugin update not pulling the marketplace shallow clone (anthropics/claude-code#40214). Session-start now auto-fetches the marketplace repo and resets if a newer version exists. - Add hooks/lib/updater.py with auto_update_marketplace() - 24h throttle via timestamp file to minimize network overhead - 5s timeout on all git operations with TimeoutExpired handling - Handle shallow clones (git fetch --unshallow before reset) - Show update notification in session greeting when new version found - 11 tests for updater module (find/throttle/update scenarios) Closes #1101
1 parent a40e93a commit 4abc229

3 files changed

Lines changed: 273 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Marketplace clone auto-updater for CodingBuddy plugin (#1101).
2+
3+
Workaround for Claude Code /plugin update not pulling the marketplace
4+
shallow clone (anthropics/claude-code#40214).
5+
"""
6+
import json
7+
import os
8+
import subprocess
9+
import time
10+
from pathlib import Path
11+
from typing import Optional
12+
13+
THROTTLE_SECONDS = 86400 # 24 hours
14+
GIT_TIMEOUT = 5 # seconds
15+
MARKETPLACE_NAME = "jeremydev87"
16+
PLUGIN_PKG_PATH = "packages/claude-code-plugin/package.json"
17+
18+
19+
def find_marketplace_clone(home: Path) -> Optional[Path]:
20+
"""Find the marketplace clone directory.
21+
22+
Returns:
23+
Path to the marketplace clone, or None if not found or not a git repo.
24+
"""
25+
clone_dir = home / ".claude" / "plugins" / "marketplaces" / MARKETPLACE_NAME
26+
if clone_dir.is_dir() and (clone_dir / ".git").exists():
27+
return clone_dir
28+
return None
29+
30+
31+
def should_check_update(timestamp_file: Path) -> bool:
32+
"""Check if enough time has passed since the last update check."""
33+
try:
34+
last_check = float(timestamp_file.read_text().strip())
35+
return (time.time() - last_check) >= THROTTLE_SECONDS
36+
except (FileNotFoundError, ValueError, OSError):
37+
return True
38+
39+
40+
def record_update_check(timestamp_file: Path) -> None:
41+
"""Record the current time as the last update check."""
42+
timestamp_file.parent.mkdir(parents=True, exist_ok=True)
43+
timestamp_file.write_text(str(time.time()))
44+
45+
46+
def _git(clone_dir: Path, *args: str) -> subprocess.CompletedProcess:
47+
"""Run a git command with timeout. Returns a failed result on timeout."""
48+
try:
49+
return subprocess.run(
50+
["git", "-C", str(clone_dir), *args],
51+
capture_output=True,
52+
text=True,
53+
timeout=GIT_TIMEOUT,
54+
)
55+
except subprocess.TimeoutExpired:
56+
return subprocess.CompletedProcess(
57+
args=["git", *args], returncode=1, stdout="", stderr="timeout"
58+
)
59+
60+
61+
def auto_update_marketplace(
62+
home: Optional[Path] = None,
63+
) -> Optional[str]:
64+
"""Auto-update the marketplace clone if a newer version is available.
65+
66+
Args:
67+
home: Home directory (default: Path.home()).
68+
69+
Returns:
70+
New version string if updated, None otherwise.
71+
"""
72+
if home is None:
73+
home = Path.home()
74+
75+
clone_dir = find_marketplace_clone(home)
76+
if clone_dir is None:
77+
return None
78+
79+
data_dir = home / ".codingbuddy"
80+
ts_file = data_dir / ".last_update_check"
81+
82+
if not should_check_update(ts_file):
83+
return None
84+
85+
# Fetch latest from remote
86+
result = _git(clone_dir, "fetch", "origin")
87+
if result.returncode != 0:
88+
return None # Network unavailable — silent fail
89+
90+
# Compare HEAD with remote
91+
head = _git(clone_dir, "rev-parse", "HEAD")
92+
remote = _git(clone_dir, "rev-parse", "origin/master")
93+
94+
if head.returncode != 0 or remote.returncode != 0:
95+
return None
96+
97+
if head.stdout.strip() == remote.stdout.strip():
98+
record_update_check(ts_file)
99+
return None # Already up to date
100+
101+
# Update: unshallow if needed, then reset
102+
_git(clone_dir, "fetch", "--unshallow", "origin") # May fail if already full — OK
103+
reset = _git(clone_dir, "reset", "--hard", "origin/master")
104+
if reset.returncode != 0:
105+
return None
106+
107+
record_update_check(ts_file)
108+
109+
# Read new version from package.json
110+
try:
111+
pkg_path = clone_dir / PLUGIN_PKG_PATH
112+
if pkg_path.exists():
113+
pkg = json.loads(pkg_path.read_text())
114+
return pkg.get("version")
115+
except (json.JSONDecodeError, OSError):
116+
pass
117+
118+
return "unknown"

packages/claude-code-plugin/hooks/session-start.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,15 @@ def main():
708708

709709
installed_hook = False
710710
registered_settings = False
711+
new_version = None
712+
713+
# Step 0.5: Auto-update marketplace clone (#1101)
714+
try:
715+
_ensure_lib_path()
716+
from updater import auto_update_marketplace
717+
new_version = auto_update_marketplace(home=home)
718+
except Exception:
719+
pass # Never block session start
711720

712721
# Step 1: Install hook file if not exists
713722
if not target_file.exists():
@@ -883,6 +892,14 @@ def main():
883892
)
884893
if output:
885894
print(output)
895+
896+
# Show update notification if marketplace clone was updated (#1101)
897+
if new_version:
898+
print(
899+
f"\n🔄 CodingBuddy v{new_version} available in marketplace!"
900+
f"\n → Run /plugin to update\n",
901+
file=sys.stderr,
902+
)
886903
except Exception:
887904
pass # Never block session start
888905

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Tests for marketplace auto-updater (#1101)."""
2+
import json
3+
import os
4+
import subprocess
5+
import tempfile
6+
import time
7+
from pathlib import Path
8+
from unittest.mock import patch, MagicMock
9+
10+
import pytest
11+
12+
import sys
13+
sys.path.insert(0, str(Path(__file__).parent.parent / "hooks" / "lib"))
14+
from updater import (
15+
find_marketplace_clone,
16+
should_check_update,
17+
record_update_check,
18+
auto_update_marketplace,
19+
THROTTLE_SECONDS,
20+
)
21+
22+
23+
class TestFindMarketplaceClone:
24+
def test_finds_jeremydev87_marketplace(self):
25+
with tempfile.TemporaryDirectory() as tmpdir:
26+
clone_dir = Path(tmpdir) / ".claude" / "plugins" / "marketplaces" / "jeremydev87"
27+
clone_dir.mkdir(parents=True)
28+
(clone_dir / ".git").mkdir()
29+
30+
result = find_marketplace_clone(Path(tmpdir))
31+
assert result == clone_dir
32+
33+
def test_returns_none_when_no_marketplace(self):
34+
with tempfile.TemporaryDirectory() as tmpdir:
35+
result = find_marketplace_clone(Path(tmpdir))
36+
assert result is None
37+
38+
def test_returns_none_when_not_git_repo(self):
39+
with tempfile.TemporaryDirectory() as tmpdir:
40+
clone_dir = Path(tmpdir) / ".claude" / "plugins" / "marketplaces" / "jeremydev87"
41+
clone_dir.mkdir(parents=True)
42+
# No .git directory
43+
44+
result = find_marketplace_clone(Path(tmpdir))
45+
assert result is None
46+
47+
48+
class TestThrottle:
49+
def test_should_check_when_no_timestamp_file(self):
50+
with tempfile.TemporaryDirectory() as tmpdir:
51+
assert should_check_update(Path(tmpdir) / "nonexistent") is True
52+
53+
def test_should_not_check_within_throttle(self):
54+
with tempfile.TemporaryDirectory() as tmpdir:
55+
ts_file = Path(tmpdir) / ".last_update_check"
56+
ts_file.write_text(str(time.time()))
57+
58+
assert should_check_update(ts_file) is False
59+
60+
def test_should_check_after_throttle_expired(self):
61+
with tempfile.TemporaryDirectory() as tmpdir:
62+
ts_file = Path(tmpdir) / ".last_update_check"
63+
ts_file.write_text(str(time.time() - THROTTLE_SECONDS - 1))
64+
65+
assert should_check_update(ts_file) is True
66+
67+
def test_record_creates_file(self):
68+
with tempfile.TemporaryDirectory() as tmpdir:
69+
ts_file = Path(tmpdir) / "sub" / ".last_update_check"
70+
record_update_check(ts_file)
71+
assert ts_file.exists()
72+
assert abs(float(ts_file.read_text()) - time.time()) < 2
73+
74+
75+
class TestAutoUpdate:
76+
def test_returns_none_when_no_marketplace(self):
77+
with tempfile.TemporaryDirectory() as tmpdir:
78+
result = auto_update_marketplace(home=Path(tmpdir))
79+
assert result is None
80+
81+
@patch("updater.subprocess.run")
82+
def test_returns_none_when_throttled(self, mock_run):
83+
with tempfile.TemporaryDirectory() as tmpdir:
84+
home = Path(tmpdir)
85+
clone_dir = home / ".claude" / "plugins" / "marketplaces" / "jeremydev87"
86+
clone_dir.mkdir(parents=True)
87+
(clone_dir / ".git").mkdir()
88+
89+
# Write recent timestamp
90+
data_dir = home / ".codingbuddy"
91+
data_dir.mkdir(parents=True)
92+
(data_dir / ".last_update_check").write_text(str(time.time()))
93+
94+
result = auto_update_marketplace(home=home)
95+
assert result is None
96+
mock_run.assert_not_called()
97+
98+
@patch("updater.subprocess.run")
99+
def test_returns_none_when_up_to_date(self, mock_run):
100+
with tempfile.TemporaryDirectory() as tmpdir:
101+
home = Path(tmpdir)
102+
clone_dir = home / ".claude" / "plugins" / "marketplaces" / "jeremydev87"
103+
clone_dir.mkdir(parents=True)
104+
(clone_dir / ".git").mkdir()
105+
106+
# Mock git commands: fetch succeeds, HEAD == origin/master
107+
mock_run.side_effect = [
108+
MagicMock(returncode=0), # git fetch
109+
MagicMock(returncode=0, stdout="abc123\n"), # git rev-parse HEAD
110+
MagicMock(returncode=0, stdout="abc123\n"), # git rev-parse origin/master
111+
]
112+
113+
result = auto_update_marketplace(home=home)
114+
assert result is None
115+
116+
@patch("updater.subprocess.run")
117+
def test_returns_version_when_updated(self, mock_run):
118+
with tempfile.TemporaryDirectory() as tmpdir:
119+
home = Path(tmpdir)
120+
clone_dir = home / ".claude" / "plugins" / "marketplaces" / "jeremydev87"
121+
clone_dir.mkdir(parents=True)
122+
(clone_dir / ".git").mkdir()
123+
124+
# Create package.json with version
125+
pkg = clone_dir / "packages" / "claude-code-plugin" / "package.json"
126+
pkg.parent.mkdir(parents=True)
127+
pkg.write_text(json.dumps({"version": "5.2.0"}))
128+
129+
mock_run.side_effect = [
130+
MagicMock(returncode=0), # git fetch
131+
MagicMock(returncode=0, stdout="abc123\n"), # git rev-parse HEAD
132+
MagicMock(returncode=0, stdout="def456\n"), # git rev-parse origin/master
133+
MagicMock(returncode=1), # git fetch --unshallow (fails = already full)
134+
MagicMock(returncode=0), # git reset --hard origin/master
135+
]
136+
137+
result = auto_update_marketplace(home=home)
138+
assert result == "5.2.0"

0 commit comments

Comments
 (0)