diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2445975 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test validation scripts + +on: + push: + paths: + - 'scripts/**' + - 'tests/**' + - '.github/workflows/test.yml' + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pip install pytest + - run: pytest tests/ -v diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7185589..622e506 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -15,73 +15,9 @@ jobs: - uses: actions/checkout@v4 - name: Validate marketplace.json schema - run: | - python3 -c " - import json, sys - with open('.claude-plugin/marketplace.json') as f: - m = json.load(f) - assert 'name' in m, 'missing name' - assert 'plugins' in m, 'missing plugins' - for p in m['plugins']: - assert 'name' in p, f'plugin missing name: {p}' - assert 'source' in p, f'plugin missing source: {p[\"name\"]}' - src = p['source'] - if isinstance(src, dict) and src.get('source') == 'git-subdir': - assert 'url' in src, f'git-subdir missing url: {p[\"name\"]}' - assert 'path' in src, f'git-subdir missing path: {p[\"name\"]}' - print(f'OK: {len(m[\"plugins\"])} plugin(s) validated') - " + run: python3 scripts/validate_schema.py .claude-plugin/marketplace.json - name: Check plugin sources are reachable env: GH_TOKEN: ${{ github.token }} - run: | - python3 -c " - import json, os, re, sys, urllib.request - with open('.claude-plugin/marketplace.json') as f: - m = json.load(f) - token = os.environ.get('GH_TOKEN', '') - api_headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'} - if token: - api_headers['Authorization'] = f'Bearer {token}' - errors = [] - for p in m['plugins']: - src = p['source'] - if not isinstance(src, dict) or src.get('source') != 'git-subdir': - continue - url = src['url'] - ref = src.get('ref', 'main') - name = p['name'] - match = re.match(r'https://github\.com/([^/]+)/([^/.]+)', url) - if not match: - errors.append(f'{name}: non-GitHub URL not supported yet: {url}') - continue - owner, repo = match.group(1), match.group(2) - # Try branch first, then tag - checked = False - for kind, api_path in [('branch', f'heads/{ref}'), ('tag', f'tags/{ref}')]: - api_url = f'https://api.github.com/repos/{owner}/{repo}/git/ref/{api_path}' - req = urllib.request.Request(api_url, headers=api_headers) - try: - with urllib.request.urlopen(req, timeout=15) as resp: - if resp.status == 200: - print(f'OK: {name} -> {owner}/{repo} ({kind}: {ref})') - checked = True - break - except urllib.error.HTTPError as e: - if e.code != 404: - errors.append(f'{name}: API error {e.code} for {kind} {ref}') - checked = True - break - except Exception as e: - errors.append(f'{name}: request failed: {e}') - checked = True - break - if not checked: - errors.append(f'{name}: ref {ref!r} not found as branch or tag in {owner}/{repo}') - if errors: - print('ERRORS:', file=sys.stderr) - for e in errors: - print(f' {e}', file=sys.stderr) - sys.exit(1) - " + run: python3 scripts/validate_reachability.py .claude-plugin/marketplace.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a09b6d9..c03c907 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ In your project repo, create `dist//` with: ``` dist// ├── .claude-plugin/ -│ └── plugin.json # name, version, userConfig, mcpServers, skills, agents, commands +│ └── plugin.json # name, userConfig, mcpServers, skills, agents, commands (no version field — see below) ├── .mcp.json # MCP server config using ${user_config.*} refs ├── skills/ # SKILL.md files ├── agents/ # agent .md files (optional) @@ -23,6 +23,8 @@ Follow the [Claude Code plugin spec](https://code.claude.com/docs/en/plugins-ref For HTTP MCP servers requiring authentication, use `userConfig` with `"sensitive": true` — **never hardcode API keys**. +**Do not set `version` in `plugin.json`.** Claude Code uses the pinned SHA as the version identifier. Every time the SHA in `marketplace.json` is updated, users automatically receive the new content. If you set an explicit version string, you would need to bump it manually on every change — the auto-pin workflow would not be enough. + ### 2. Open a PR here Add an entry to `.claude-plugin/marketplace.json`: @@ -34,24 +36,126 @@ Add an entry to `.claude-plugin/marketplace.json`: "source": "git-subdir", "url": "https://github.com/Viindoo/.git", "path": "dist/", - "ref": "main", + "ref": "master", "sha": "" }, "description": "One-line description (max 120 chars)" } ``` -Pin `sha` to the exact commit where your plugin landed on `main`. This is the anti-drift anchor — the nightly CI checks this SHA is still reachable. +Pin `sha` to the exact commit where your plugin landed. This is the anti-drift anchor — the nightly CI checks this SHA is still reachable. + +### 3. Set up auto-pinning in your plugin repo + +After registering the plugin, wire up the auto-pin workflow so that every merge to `master` that touches `dist//` automatically opens a SHA-update PR here. + +**Required setup (one-time per plugin repo):** + +1. **Create a fine-grained PAT** targeting `Viindoo/claude-plugins` with permissions: + - `Contents: Read and write` + - `Pull requests: Read and write` + +2. **Add the PAT as a secret** in your plugin repo: + `Settings → Secrets → Actions → New repository secret` + Name: `CLAUDE_PLUGINS_PAT` + +3. **Create the `auto-pin` label** in `Viindoo/claude-plugins`: + ```bash + gh label create auto-pin --repo Viindoo/claude-plugins --color 0075ca --description "Automated SHA pin update" + ``` + +4. **Add the workflow** `.github/workflows/pin-sha.yml` to your plugin repo (see template below). + +**Template `pin-sha.yml`** (replace `` and ``): + +```yaml +name: Pin SHA in claude-plugins + +on: + push: + branches: [master] + paths: + - 'dist//**' + +jobs: + pin-sha: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Get SHA + id: info + run: echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + - uses: actions/checkout@v4 + with: + repository: Viindoo/claude-plugins + token: ${{ secrets.CLAUDE_PLUGINS_PAT }} + path: claude-plugins + + - name: Check if already pinned + id: check + run: | + CURRENT=$(python3 -c " + import json + m = json.load(open('claude-plugins/.claude-plugin/marketplace.json')) + for p in m['plugins']: + if p['name'] == '': + print(p['source'].get('sha', '')) + ") + [ "$CURRENT" = "${{ steps.info.outputs.sha }}" ] && echo "skip=true" >> $GITHUB_OUTPUT || echo "skip=false" >> $GITHUB_OUTPUT + + - name: Update marketplace.json + if: steps.check.outputs.skip == 'false' + run: | + python3 -c " + import json + path = 'claude-plugins/.claude-plugin/marketplace.json' + with open(path) as f: m = json.load(f) + for p in m['plugins']: + if p['name'] == '': + p['source']['sha'] = '${{ steps.info.outputs.sha }}' + with open(path, 'w') as f: json.dump(m, f, indent=2); f.write('\n') + " + + - name: Create PR with auto-merge + if: steps.check.outputs.skip == 'false' + working-directory: claude-plugins + env: + GH_TOKEN: ${{ secrets.CLAUDE_PLUGINS_PAT }} + run: | + SHA="${{ steps.info.outputs.sha }}" + SHA7="${SHA:0:7}" + BRANCH="auto-pin/-${SHA7}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add .claude-plugin/marketplace.json + git commit -m "pin to ${SHA7}" + git push origin "$BRANCH" + PR_URL=$(gh pr create \ + --repo Viindoo/claude-plugins \ + --title "pin to ${SHA7}" \ + --body "Auto-pin: plugin content changed in [${SHA7}](https://github.com/Viindoo//commit/${SHA})." \ + --label "auto-pin") + gh pr merge --auto --squash "$PR_URL" +``` + +**Enable auto-merge in `Viindoo/claude-plugins`** (one-time repo setting): +`Settings → General → Pull Requests → Allow auto-merge` ✓ + +**Enable branch protection on `master`** (required for auto-merge to work): +`Settings → Branches → Add rule → master → Require status checks → validate` -### 3. After your PR merges +### 4. After the first PR merges -Bump the `sha` here whenever you ship a new plugin version. Open a follow-up PR with the new SHA + updated `ref` if needed. +Subsequent plugin updates are fully automatic — the `pin-sha.yml` workflow handles everything. No manual SHA updates needed. ## Anti-drift CI `.github/workflows/validate.yml` runs nightly and on every change to `marketplace.json`: -- Validates JSON schema -- Checks each `git-subdir` source URL + ref is still reachable via `git ls-remote` +- Validates JSON schema (`scripts/validate_schema.py`) +- Checks each `git-subdir` source URL + ref is still reachable via GitHub API (`scripts/validate_reachability.py`) If CI fails: the plugin source has moved or been deleted. Update `marketplace.json` to the new location. diff --git a/README.md b/README.md index bc60edf..8d14ea8 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,11 @@ Or in Claude Code interactive session: Each Viindoo project hosts its own plugin source under `dist//`. To add a new plugin to this marketplace: -1. Create `dist//` in your project repo with `.claude-plugin/plugin.json` +1. Create `dist//` in your project repo with `.claude-plugin/plugin.json` (no `version` field — SHA is used as the version identifier) 2. Open a PR here adding an entry to `.claude-plugin/marketplace.json` 3. Pin `sha` to the exact commit of your plugin source after it merges +4. Set up `.github/workflows/pin-sha.yml` in your plugin repo — subsequent updates are then fully automatic + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full setup guide including the auto-pin workflow template. See [Anti-drift CI](.github/workflows/validate.yml) — nightly validation checks each plugin source is still reachable and schema-valid. diff --git a/scripts/validate_reachability.py b/scripts/validate_reachability.py new file mode 100644 index 0000000..23e9838 --- /dev/null +++ b/scripts/validate_reachability.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import json, os, re, sys, urllib.error, urllib.request + + +def check(path): + with open(path) as f: + m = json.load(f) + token = os.environ.get('GH_TOKEN', '') + headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'} + if token: + headers['Authorization'] = f'Bearer {token}' + errors = [] + for p in m['plugins']: + src = p['source'] + if not isinstance(src, dict) or src.get('source') != 'git-subdir': + continue + url = src['url'] + ref = src.get('ref', 'main') + name = p['name'] + match = re.match(r'https://github\.com/([^/]+)/([^/.]+)', url) + if not match: + errors.append(f'{name}: non-GitHub URL not supported yet: {url}') + continue + owner, repo = match.group(1), match.group(2) + checked = False + for kind, api_path in [('branch', f'heads/{ref}'), ('tag', f'tags/{ref}')]: + api_url = f'https://api.github.com/repos/{owner}/{repo}/git/ref/{api_path}' + req = urllib.request.Request(api_url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status == 200: + print(f'OK: {name} -> {owner}/{repo} ({kind}: {ref})') + checked = True + break + except urllib.error.HTTPError as e: + if e.code != 404: + errors.append(f'{name}: API error {e.code} for {kind} {ref}') + checked = True + break + except Exception as e: + errors.append(f'{name}: request failed: {e}') + checked = True + break + if not checked: + errors.append(f'{name}: ref {ref!r} not found as branch or tag in {owner}/{repo}') + return errors + + +if __name__ == '__main__': + if len(sys.argv) != 2: + print(f'usage: {sys.argv[0]} ', file=sys.stderr) + sys.exit(1) + errors = check(sys.argv[1]) + if errors: + print('ERRORS:', file=sys.stderr) + for e in errors: + print(f' {e}', file=sys.stderr) + sys.exit(1) diff --git a/scripts/validate_schema.py b/scripts/validate_schema.py new file mode 100644 index 0000000..0497935 --- /dev/null +++ b/scripts/validate_schema.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +import json, sys + + +def validate(path): + with open(path) as f: + m = json.load(f) + errors = [] + if 'name' not in m: + errors.append('missing top-level name') + if 'plugins' not in m: + errors.append('missing top-level plugins') + return errors + for p in m['plugins']: + if 'name' not in p: + errors.append(f'plugin missing name: {p}') + continue + if 'source' not in p: + errors.append(f'plugin missing source: {p["name"]}') + continue + src = p['source'] + if isinstance(src, dict) and src.get('source') == 'git-subdir': + if 'url' not in src: + errors.append(f'git-subdir missing url: {p["name"]}') + if 'path' not in src: + errors.append(f'git-subdir missing path: {p["name"]}') + return errors + + +if __name__ == '__main__': + if len(sys.argv) != 2: + print(f'usage: {sys.argv[0]} ', file=sys.stderr) + sys.exit(1) + errors = validate(sys.argv[1]) + if errors: + print('ERRORS:', file=sys.stderr) + for e in errors: + print(f' {e}', file=sys.stderr) + sys.exit(1) + with open(sys.argv[1]) as f: + m = json.load(f) + print(f'OK: {len(m["plugins"])} plugin(s) validated') diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_validate_reachability.py b/tests/test_validate_reachability.py new file mode 100644 index 0000000..ba94836 --- /dev/null +++ b/tests/test_validate_reachability.py @@ -0,0 +1,104 @@ +import io, json, os, sys, tempfile +from unittest.mock import MagicMock, patch +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts')) +from validate_reachability import check + + +def _write(tmp_path, data): + p = tmp_path / 'marketplace.json' + p.write_text(json.dumps(data)) + return str(p) + + +def _marketplace(ref='master'): + return { + 'name': 'test', + 'plugins': [ + { + 'name': 'my-plugin', + 'source': { + 'source': 'git-subdir', + 'url': 'https://github.com/Viindoo/my-repo.git', + 'path': 'dist/my-plugin', + 'ref': ref, + }, + } + ], + } + + +def _mock_resp(status=200): + resp = MagicMock() + resp.status = status + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + +def test_ok_on_200(tmp_path, capsys): + with patch('urllib.request.urlopen', return_value=_mock_resp(200)): + errors = check(_write(tmp_path, _marketplace())) + assert errors == [] + assert 'OK: my-plugin' in capsys.readouterr().out + + +def test_error_on_403(tmp_path): + import urllib.error + http_403 = urllib.error.HTTPError(url='', code=403, msg='Forbidden', hdrs={}, fp=None) + with patch('urllib.request.urlopen', side_effect=http_403): + errors = check(_write(tmp_path, _marketplace())) + assert any('403' in e for e in errors) + + +def test_ref_not_found_when_both_404(tmp_path): + import urllib.error + http_404 = urllib.error.HTTPError(url='', code=404, msg='Not Found', hdrs={}, fp=None) + with patch('urllib.request.urlopen', side_effect=http_404): + errors = check(_write(tmp_path, _marketplace())) + assert any('not found' in e for e in errors) + + +def test_non_github_url(tmp_path): + d = _marketplace() + d['plugins'][0]['source']['url'] = 'https://gitlab.com/some/repo.git' + errors = check(_write(tmp_path, d)) + assert any('not supported' in e for e in errors) + + +def test_non_git_subdir_skipped(tmp_path): + d = {'name': 'test', 'plugins': [{'name': 'p', 'source': 'local'}]} + with patch('urllib.request.urlopen') as mock_open: + errors = check(_write(tmp_path, d)) + mock_open.assert_not_called() + assert errors == [] + + +def test_gh_token_injected_as_auth_header(tmp_path): + captured_req = {} + + def fake_urlopen(req, timeout=None): + captured_req['headers'] = req.headers + return _mock_resp(200) + + with patch.dict(os.environ, {'GH_TOKEN': 'test-token-xyz'}): + with patch('urllib.request.urlopen', side_effect=fake_urlopen): + check(_write(tmp_path, _marketplace())) + + assert captured_req['headers'].get('Authorization') == 'Bearer test-token-xyz' + + +def test_no_token_omits_auth_header(tmp_path): + captured_req = {} + + def fake_urlopen(req, timeout=None): + captured_req['headers'] = req.headers + return _mock_resp(200) + + env = {k: v for k, v in os.environ.items() if k != 'GH_TOKEN'} + with patch.dict(os.environ, env, clear=True): + with patch('urllib.request.urlopen', side_effect=fake_urlopen): + check(_write(tmp_path, _marketplace())) + + assert 'Authorization' not in captured_req['headers'] diff --git a/tests/test_validate_schema.py b/tests/test_validate_schema.py new file mode 100644 index 0000000..87de584 --- /dev/null +++ b/tests/test_validate_schema.py @@ -0,0 +1,87 @@ +import json, os, sys, tempfile +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts')) +from validate_schema import validate + + +def _write(tmp_path, data): + p = tmp_path / 'marketplace.json' + p.write_text(json.dumps(data)) + return str(p) + + +def _valid(): + return { + 'name': 'test-marketplace', + 'plugins': [ + { + 'name': 'my-plugin', + 'source': { + 'source': 'git-subdir', + 'url': 'https://github.com/Viindoo/my-repo.git', + 'path': 'dist/my-plugin', + }, + 'description': 'A test plugin', + } + ], + } + + +def test_valid_passes(tmp_path): + assert validate(_write(tmp_path, _valid())) == [] + + +def test_missing_top_level_name(tmp_path): + d = _valid() + del d['name'] + errors = validate(_write(tmp_path, d)) + assert any('name' in e for e in errors) + + +def test_missing_plugins(tmp_path): + d = _valid() + del d['plugins'] + errors = validate(_write(tmp_path, d)) + assert any('plugins' in e for e in errors) + + +def test_plugin_missing_name(tmp_path): + d = _valid() + del d['plugins'][0]['name'] + errors = validate(_write(tmp_path, d)) + assert any('missing name' in e for e in errors) + + +def test_plugin_missing_source(tmp_path): + d = _valid() + del d['plugins'][0]['source'] + errors = validate(_write(tmp_path, d)) + assert any('missing source' in e for e in errors) + + +def test_git_subdir_missing_url(tmp_path): + d = _valid() + del d['plugins'][0]['source']['url'] + errors = validate(_write(tmp_path, d)) + assert any('missing url' in e for e in errors) + + +def test_git_subdir_missing_path(tmp_path): + d = _valid() + del d['plugins'][0]['source']['path'] + errors = validate(_write(tmp_path, d)) + assert any('missing path' in e for e in errors) + + +def test_non_git_subdir_source_skips_url_check(tmp_path): + d = _valid() + d['plugins'][0]['source'] = 'local' + errors = validate(_write(tmp_path, d)) + assert errors == [] + + +def test_sha_field_optional(tmp_path): + d = _valid() + d['plugins'][0]['source']['sha'] = 'abc1234' + assert validate(_write(tmp_path, d)) == []