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)