From c4b8f8f75e99ffe3af9e17c2c9e5f4b08ce14555 Mon Sep 17 00:00:00 2001 From: yuhangsigma <293912480+yuhangsigma@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:19:04 -0400 Subject: [PATCH] Accept a Claude plugin at plugins/claude itself Validate and Release assumed every plugin sits at plugins///. The Claude plugin is becoming one plugin for every region, at plugins/claude/, so: - Validate checks its skills at depth 1 or 2, and treats plugins/claude as the plugin when it holds a manifest. - Release reads its version from plugins/claude, and zips it once as -.claude.plugin. The previous layout, a folder per plugin, still passes both workflows, so this can merge before the layout changes. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/release.yml | 23 +++++++++++++++++++---- .github/workflows/validate.yml | 9 ++++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24e3a01..6566b34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,13 @@ jobs: for host_dir in sorted(Path("plugins").iterdir()): if host_dir.name not in HOSTS: raise SystemExit(f"plugins/{host_dir.name} isn't a known host: {sorted(HOSTS)}") - for plugin in sorted(p for p in host_dir.iterdir() if p.is_dir()): + folders = sorted(p for p in host_dir.iterdir() if p.is_dir()) + # Claude has one plugin, plugins/claude itself, not a folder + # per plugin inside it. The per-folder layout is still + # accepted until the first sync in the new one lands. + if host_dir.name == "claude" and (host_dir / HOSTS["claude"]).is_file(): + folders = [host_dir] + for plugin in folders: manifest = plugin / HOSTS[host_dir.name] if not manifest.is_file(): raise SystemExit(f"{plugin} has no {HOSTS[host_dir.name]}") @@ -101,20 +107,29 @@ jobs: echo "exists=true" >> "$GITHUB_OUTPUT" # A Claude archive holds the plugin folder's contents, which is what an - # upload install expects. The other hosts' archives hold the folder - # itself, so unzipping one gives a folder to install from. + # upload install expects. When plugins/claude is itself the plugin, it is + # zipped once and named for the plugin in its manifest. The other hosts' + # archives hold the folder itself, so unzipping one gives a folder to + # install from. - name: Archive each plugin if: steps.released.outputs.exists == 'false' env: SLUG: ${{ steps.version.outputs.slug }} run: | mkdir dist + flat="" + if [ -f plugins/claude/.claude-plugin/plugin.json ]; then + flat=1 + name="$(python3 -c "import json; print(json.load(open('plugins/claude/.claude-plugin/plugin.json'))['name'])")" + # -y stores a symlink as a link instead of following it. + (cd plugins/claude && zip -qry "../../dist/${name}-${SLUG}.claude.plugin" . -x '*.DS_Store') + fi for dir in plugins/*/*/; do host="$(basename "$(dirname "$dir")")" name="$(basename "$dir")" - # -y stores a symlink as a link instead of following it. case "$host" in claude) + [ -n "$flat" ] && continue # archived above (cd "$dir" && zip -qry "../../../dist/${name}-${SLUG}.claude.plugin" . -x '*.DS_Store') ;; codex|cursor|coco) (cd "plugins/$host" && zip -qry "../../dist/${name}-${SLUG}.${host}.zip" "$name" -x '*.DS_Store') ;; diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e5dd1bb..3170ef4 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -49,8 +49,10 @@ jobs: - name: Validate every plugin manifest run: find plugins/claude -path '*/.claude-plugin/plugin.json' -print0 | xargs -0 -n1 claude plugin validate --strict + # Claude's one plugin is plugins/claude itself, so its skills are at depth + # 1; an older layout with a plugin folder per region has them at depth 2. - name: Validate every plugin's skills - run: find plugins/claude -mindepth 2 -maxdepth 2 -type d -name skills -print0 | xargs -0 -n1 claude plugin validate --strict + run: find plugins/claude -mindepth 1 -maxdepth 2 -type d -name skills -print0 | xargs -0 -n1 claude plugin validate --strict # The Claude CLI checks only Claude plugins. For Codex, Cursor and # Cortex Code: every JSON file parses, every plugin folder has its @@ -84,6 +86,11 @@ jobs: for host, manifest in MANIFESTS.items(): host_dir = Path("plugins", host) plugins = sorted(p for p in host_dir.iterdir() if p.is_dir()) if host_dir.is_dir() else [] + # Claude has one plugin, plugins/claude itself, not a folder per + # plugin inside it. The per-folder layout is still accepted until + # the first sync in the new one lands. + if host == "claude" and (host_dir / manifest).is_file(): + plugins = [host_dir] if host != "claude": for f in sorted(host_dir.rglob("*.json")): load(f)