Files
2026-07-13 13:11:15 +08:00

341 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
MODE="write"
CLEAN=0
SOURCE_SKILLS_ROOT="$REPO_ROOT/skills"
PLUGIN_ROOT="$REPO_ROOT/plugins/cad"
TARGET_SKILLS_ROOT="$PLUGIN_ROOT/skills"
CHECK_DIR="${PLUGIN_BUNDLE_CHECK_DIR:-${PLUGIN_BUILD_CHECK_DIR:-$REPO_ROOT/tmp/plugin-cad-check}}"
LIST_SKILLS_SCRIPT="$REPO_ROOT/scripts/utils/list-skills.sh"
PYTHON_BIN="${PYTHON_BIN:-python3}"
usage() {
cat <<'EOF'
Usage:
scripts/bundle/bundle-plugin.sh [--check] [--clean]
Bundles the installable cad plugin package by copying the root skills/
sources into plugins/cad/skills. The plugin package must not contain symlinks
because provider installers cache plugin roots independently of this checkout.
Options:
--check Bundle into tmp/ and fail if plugin outputs or metadata are stale.
--clean Remove temporary bundle/check directories first.
-h, --help
Show this help.
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--check)
MODE="check"
;;
--clean)
CLEAN=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
shift
done
ensure_deps() {
if ! command -v rsync >/dev/null 2>&1; then
echo "rsync is required to bundle plugin skill copies." >&2
exit 1
fi
if [ ! -d "$SOURCE_SKILLS_ROOT" ]; then
echo "Missing source skills directory: $SOURCE_SKILLS_ROOT" >&2
exit 1
fi
if [ ! -d "$PLUGIN_ROOT" ]; then
echo "Missing plugin directory: $PLUGIN_ROOT" >&2
exit 1
fi
if [ ! -x "$LIST_SKILLS_SCRIPT" ]; then
echo "Missing skill list script: $LIST_SKILLS_SCRIPT" >&2
exit 1
fi
}
list_skills() {
"$LIST_SKILLS_SCRIPT"
}
assert_no_symlinks() {
local target_dir="$1"
local first_link
first_link="$(find "$target_dir" -type l -print -quit)"
if [ -n "$first_link" ]; then
echo "Plugin skill copy contains a symlink: $first_link" >&2
echo "Run scripts/bundle/bundle-plugin.sh to bundle plugin skill copies." >&2
exit 1
fi
}
sync_skills() {
local target_root="$1"
local skill
rm -rf "$target_root"
mkdir -p "$target_root"
while IFS= read -r skill; do
local source_dir="$SOURCE_SKILLS_ROOT/$skill"
local target_dir="$target_root/$skill"
if [ ! -d "$source_dir" ]; then
echo "Missing source skill directory: skills/$skill" >&2
exit 1
fi
if [ ! -f "$source_dir/SKILL.md" ]; then
echo "Missing source skill manifest: skills/$skill/SKILL.md" >&2
exit 1
fi
mkdir -p "$target_dir"
rsync -aL --delete \
--delete-excluded \
--exclude __pycache__ \
--exclude .pytest_cache \
--exclude node_modules \
--exclude '*.pyc' \
--exclude tests \
--exclude __tests__ \
--exclude 'test_*.py' \
--exclude '*_test.py' \
--exclude '*.test.js' \
--exclude '*.test.mjs' \
--exclude '*.test.ts' \
--exclude '*.test.tsx' \
--exclude '*.spec.js' \
--exclude '*.spec.mjs' \
--exclude '*.spec.ts' \
--exclude '*.spec.tsx' \
"$source_dir/" "$target_dir/"
done < <(list_skills)
assert_no_symlinks "$target_root"
}
check_skill_names() {
local target_root="$1"
local expected actual
expected="$(list_skills)"
actual="$(find "$target_root" -maxdepth 1 -mindepth 1 -type d -exec basename {} \; | sort)"
if [ "$actual" != "$expected" ]; then
echo "Plugin skill list is stale." >&2
echo "Expected:" >&2
printf '%s\n' "$expected" >&2
echo "Actual:" >&2
printf '%s\n' "$actual" >&2
exit 1
fi
}
check_skills() {
local check_skills_root="$CHECK_DIR/skills"
local first_link
if [ ! -d "$TARGET_SKILLS_ROOT" ]; then
echo "Missing generated plugin skill copy: plugins/cad/skills" >&2
echo "Run scripts/bundle/bundle-plugin.sh and commit plugins/cad/skills." >&2
exit 1
fi
first_link="$(find "$TARGET_SKILLS_ROOT" -type l -print -quit)"
if [ -n "$first_link" ]; then
"$REPO_ROOT/scripts/dev/setup-plugin-symlink.sh" --check
echo "Plugin skill copy is in development symlink layout; production copy freshness is checked on build-test/main."
return
fi
assert_no_symlinks "$TARGET_SKILLS_ROOT"
check_skill_names "$TARGET_SKILLS_ROOT"
if ! diff -qr \
-x __pycache__ \
-x .pytest_cache \
-x '*.pyc' \
"$check_skills_root" "$TARGET_SKILLS_ROOT" >/tmp/plugin-cad-skills-diff.txt; then
cat /tmp/plugin-cad-skills-diff.txt >&2
echo "" >&2
echo "Plugin skill copy is stale." >&2
echo "Run scripts/bundle/bundle-plugin.sh and commit plugins/cad/skills." >&2
exit 1
fi
echo "Plugin skill copy is up to date."
}
validate_plugin() {
"$PYTHON_BIN" - "$REPO_ROOT" <<'PY'
from __future__ import annotations
import json
import sys
from pathlib import Path
repo_root = Path(sys.argv[1])
plugin_name = "cad"
skills = sorted(
path.name
for path in (repo_root / "skills").iterdir()
if path.is_dir() and (path / "SKILL.md").is_file()
)
plugin_root = repo_root / "plugins" / plugin_name
codex_manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
claude_manifest_path = plugin_root / ".claude-plugin" / "plugin.json"
claude_marketplace_path = repo_root / ".claude-plugin" / "marketplace.json"
codex_marketplace_path = repo_root / ".codex-plugin" / "marketplace.json"
version_path = plugin_root / "VERSION"
errors: list[str] = []
def require(condition: bool, message: str) -> None:
if not condition:
errors.append(message)
require(plugin_root.is_dir(), f"missing plugin directory: {plugin_root}")
require(codex_manifest_path.is_file(), f"missing Codex plugin manifest: {codex_manifest_path}")
require(claude_manifest_path.is_file(), f"missing Claude plugin manifest: {claude_manifest_path}")
require(claude_marketplace_path.is_file(), f"missing Claude marketplace: {claude_marketplace_path}")
require(codex_marketplace_path.is_file(), f"missing Codex marketplace: {codex_marketplace_path}")
require(version_path.is_file(), f"missing plugin version file: {version_path}")
def load_json_object(path: Path) -> dict[str, object]:
if not path.is_file():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
errors.append(f"invalid JSON in {path}: {exc}")
return {}
if not isinstance(payload, dict):
errors.append(f"{path} must contain a JSON object")
return {}
return payload
def plugin_version() -> str:
if not version_path.is_file():
return ""
return version_path.read_text(encoding="utf-8").strip()
version = plugin_version()
def validate_plugin_manifest(path: Path, provider: str) -> None:
manifest = load_json_object(path)
if not manifest:
return
require(manifest.get("name") == plugin_name, f"{provider} plugin manifest name is stale")
require(manifest.get("version") == version, f"{provider} plugin manifest version is stale")
require(manifest.get("skills") in {"./skills/", "./skills", "skills"}, f"{provider} plugin manifest must point at ./skills/")
validate_plugin_manifest(codex_manifest_path, "Codex")
validate_plugin_manifest(claude_manifest_path, "Claude")
codex_marketplace = load_json_object(codex_marketplace_path)
if codex_marketplace:
require(codex_marketplace.get("name") == "text-to-cad", "Codex marketplace name is stale")
plugins = codex_marketplace.get("plugins")
if not isinstance(plugins, list):
errors.append("Codex marketplace plugins must be an array")
else:
cad_entries = [
entry for entry in plugins
if isinstance(entry, dict) and entry.get("name") == plugin_name
]
require(len(cad_entries) == 1, "Codex marketplace must contain exactly one cad plugin entry")
if cad_entries:
source = cad_entries[0].get("source")
if not isinstance(source, dict):
errors.append("Codex marketplace cad source must be an object")
else:
require(source.get("path") == "./plugins/cad", "Codex marketplace cad source path is stale")
claude_marketplace = load_json_object(claude_marketplace_path)
if claude_marketplace:
require(claude_marketplace.get("name") == "text-to-cad", "Claude marketplace name is stale")
require(claude_marketplace.get("version") == version, "Claude marketplace version is stale")
plugins = claude_marketplace.get("plugins")
if not isinstance(plugins, list):
errors.append("Claude marketplace plugins must be an array")
else:
cad_entries = [
entry for entry in plugins
if isinstance(entry, dict) and entry.get("name") == plugin_name
]
require(len(cad_entries) == 1, "Claude marketplace must contain exactly one cad plugin entry")
if cad_entries:
entry = cad_entries[0]
require(entry.get("source") == "./plugins/cad", "Claude marketplace cad source is stale")
require(entry.get("version") == version, "Claude marketplace cad version is stale")
skills_root = plugin_root / "skills"
for skill in skills:
root_skill = repo_root / "skills" / skill
bundled_skill = skills_root / skill
require(root_skill.is_dir(), f"missing source skill: {root_skill}")
require((root_skill / "SKILL.md").is_file(), f"missing source skill manifest: {root_skill / 'SKILL.md'}")
require(bundled_skill.is_dir(), f"plugin skill copy must be a directory: {bundled_skill}")
if bundled_skill.is_symlink():
require(
bundled_skill.resolve() == root_skill.resolve(),
f"plugin development symlink points at the wrong skill: {bundled_skill}",
)
require((bundled_skill / "SKILL.md").is_file(), f"missing plugin skill manifest: {bundled_skill / 'SKILL.md'}")
if skills_root.is_dir():
for path in skills_root.rglob("*"):
if path.is_symlink():
if path.parent != skills_root:
errors.append(f"plugin skill copy must not contain nested symlinks: {path}")
if skills_root.is_dir():
bundled_names = sorted(path.name for path in skills_root.iterdir() if not path.name.startswith("."))
require(bundled_names == skills, f"cad plugin bundled skill list is stale: {bundled_names}")
if errors:
print("Plugin validation failed:")
for error in errors:
print(f"- {error}")
raise SystemExit(1)
print(f"Plugin validation passed: {plugin_root}")
PY
}
ensure_deps
if [ "$CLEAN" -eq 1 ]; then
rm -rf "$CHECK_DIR"
fi
if [ "$MODE" = "check" ]; then
sync_skills "$CHECK_DIR/skills"
check_skills
validate_plugin
else
sync_skills "$TARGET_SKILLS_ROOT"
echo "Bundled plugins/cad/skills"
fi