chore: import upstream snapshot with attribution
This commit is contained in:
Executable
+340
@@ -0,0 +1,340 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user