mirror of
https://github.com/secondsky/claude-skills.git
synced 2026-09-18 19:54:22 +08:00
b9627cae5d
- generate-marketplace.sh: set -o pipefail (added in #85) made find|wc -l on a missing skills/ dir abort the whole generator. Append || true so a plugin lacking skills/ reports count 0. - generate-marketplace.sh: reject an empty marketplace (plugin_count=0) before publishing — all-skipped runs previously produced {"plugins":[]} violating the schema minItems. Now exits 1. - review-skill.sh: strip YAML trailing comment/whitespace BEFORE quotes so 'value' # comment and "value" # comment both parse; the prior order left a stray closing quote on single-quoted-with-comment values. - sync-plugins.sh: fold mv into the jq && jq && mv chain so an mv failure routes to the else-cleanup branch instead of aborting under set -e. All verified: pipefail missing-dir survives; empty-mp guard fires; quote-strip handles 5 forms; sync dry-run green; validators green.
762 lines
28 KiB
Bash
Executable File
762 lines
28 KiB
Bash
Executable File
#!/bin/bash
|
|
# =============================================================================
|
|
# Unified Plugin Synchronization Script
|
|
# =============================================================================
|
|
# Single entry point for all plugin management in claude-skills repository.
|
|
#
|
|
# This script consolidates functionality from:
|
|
# - generate-plugin-manifests.sh (deprecated)
|
|
# - populate-keywords.sh (deprecated)
|
|
# - generate-marketplace.sh (still used for marketplace generation)
|
|
#
|
|
# Features:
|
|
# 1. Syncs version from marketplace.json to all plugin.json files
|
|
# 2. Adds/updates category field based on skill name patterns
|
|
# 3. Detects agents/ directory → adds agents array
|
|
# 4. Detects commands/ directory → adds commands array
|
|
# 5. Generates keywords from skill name, category, and description
|
|
# 6. Regenerates marketplace.json with updated data
|
|
#
|
|
# ⚠️ VERSIONING: This script uses a SINGLE GLOBAL VERSION for all plugins.
|
|
# The version is read from marketplace.json → .metadata.version and stamped
|
|
# onto every plugin.json and marketplace entry. There is no per-plugin
|
|
# versioning. To bump any plugin's version:
|
|
# 1. Update .metadata.version in marketplace.json
|
|
# 2. Run this script (all plugins get the same version)
|
|
# Do NOT manually edit plugin.json version — sync will overwrite it.
|
|
#
|
|
# Usage:
|
|
# ./scripts/sync-plugins.sh # Full sync
|
|
# ./scripts/sync-plugins.sh --dry-run # Preview changes without modifying
|
|
# ./scripts/sync-plugins.sh --help # Show help
|
|
#
|
|
# =============================================================================
|
|
|
|
set -e
|
|
|
|
command -v jq &>/dev/null || { echo "Error: jq is required but not installed"; exit 1; }
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
PLUGINS_DIR="$ROOT_DIR/plugins"
|
|
MARKETPLACE_JSON="$ROOT_DIR/.claude-plugin/marketplace.json"
|
|
|
|
# Parse arguments
|
|
DRY_RUN=false
|
|
for arg in "$@"; do
|
|
case $arg in
|
|
--dry-run) DRY_RUN=true ;;
|
|
--help|-h)
|
|
echo "Usage: $0 [--dry-run]"
|
|
echo ""
|
|
echo "Options:"
|
|
echo " --dry-run Preview changes without modifying files"
|
|
echo " --help Show this help message"
|
|
exit 0
|
|
;;
|
|
esac
|
|
done
|
|
|
|
echo "============================================"
|
|
echo "Claude Skills - Plugin Sync"
|
|
echo "============================================"
|
|
if [ "$DRY_RUN" = true ]; then
|
|
echo "Mode: DRY RUN (no files will be modified)"
|
|
fi
|
|
echo ""
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Read global version from marketplace.json
|
|
# -----------------------------------------------------------------------------
|
|
GLOBAL_VERSION=$(jq -r '.metadata.version // "1.0.0"' "$MARKETPLACE_JSON" 2>/dev/null)
|
|
echo "Global version: $GLOBAL_VERSION"
|
|
echo "Plugins directory: $PLUGINS_DIR"
|
|
echo ""
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Load shared categorization library
|
|
# -----------------------------------------------------------------------------
|
|
source "$SCRIPT_DIR/lib/categorize.sh"
|
|
|
|
# categorize_skill() function is now loaded from lib/categorize.sh
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Function: Get category-specific keywords
|
|
# -----------------------------------------------------------------------------
|
|
get_category_keywords() {
|
|
local category="$1"
|
|
|
|
case "$category" in
|
|
"cloudflare") echo "cloudflare,workers,edge,wrangler" ;;
|
|
"ai") echo "ai,machine-learning,ml,llm,artificial-intelligence" ;;
|
|
"frontend") echo "frontend,ui,components,typescript" ;;
|
|
"auth") echo "authentication,authorization,login,security,session" ;;
|
|
"storage") echo "storage,object-storage,blob,upload,files,cdn,assets" ;;
|
|
"database") echo "database,orm,sql,query,migrations" ;;
|
|
"api") echo "api,rest,graphql,endpoints,http" ;;
|
|
"testing") echo "testing,tests,unit-tests,integration,quality" ;;
|
|
"security") echo "security,vulnerability,protection,csrf,xss" ;;
|
|
"mobile") echo "mobile,app,native" ;;
|
|
"web") echo "web,performance,optimization,pwa" ;;
|
|
"seo") echo "seo,search,keywords,optimization,ranking" ;;
|
|
"design") echo "design,ui,ux,interface,user-experience" ;;
|
|
"data") echo "data,analytics,processing,sql,queries" ;;
|
|
"documentation") echo "documentation,docs,technical-writing,specs" ;;
|
|
"architecture") echo "architecture,patterns,microservices,design" ;;
|
|
"woocommerce") echo "woocommerce,wordpress,ecommerce,shop" ;;
|
|
"cms") echo "cms,content,management,publishing" ;;
|
|
*) echo "development,tooling,workflow" ;;
|
|
esac
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Function: Filter category keywords for specific skills
|
|
# -----------------------------------------------------------------------------
|
|
filter_category_keywords() {
|
|
local skill_name="$1"
|
|
local category="$2"
|
|
local category_kws="$3"
|
|
|
|
# Skills that should NOT get certain category defaults
|
|
case "$skill_name" in
|
|
internationalization-i18n)
|
|
# Remove web performance keywords - i18n is not about performance/pwa
|
|
echo "$category_kws" | tr ',' '\n' | grep -v -E '^(pwa|performance|optimization)$' | tr '\n' ',' | sed 's/,$//'
|
|
;;
|
|
ml-model-training)
|
|
# Remove LLM keyword - this is for general ML, not LLM training
|
|
echo "$category_kws" | tr ',' '\n' | grep -v -E '^llm$' | tr '\n' ',' | sed 's/,$//'
|
|
;;
|
|
access-control-rbac)
|
|
# RBAC is about roles/permissions, not attack prevention
|
|
echo "$category_kws" | tr ',' '\n' | grep -v -E '^(csrf|xss)$' | tr '\n' ',' | sed 's/,$//'
|
|
;;
|
|
mobile-first-design)
|
|
# Responsive web design, not native mobile apps
|
|
# Remove misleading mobile keywords, add responsive design keywords
|
|
filtered=$(echo "$category_kws" | tr ',' '\n' | grep -v -E '^(app|native)$' | tr '\n' ',' | sed 's/,$//')
|
|
echo "$filtered,responsive-design,media-queries,breakpoints"
|
|
;;
|
|
websocket-implementation)
|
|
# WebSockets, not REST/GraphQL
|
|
echo "$category_kws" | tr ',' '\n' | grep -v -E '^(rest|graphql|endpoints)$' | tr '\n' ',' | sed 's/,$//'
|
|
;;
|
|
payment-gateway-integration)
|
|
# Payment integration, not PWA/performance optimization
|
|
echo "$category_kws" | tr ',' '\n' | grep -v -E '^(pwa|performance)$' | tr '\n' ',' | sed 's/,$//'
|
|
;;
|
|
push-notification-setup)
|
|
# Keep all - notifications are a web/mobile feature
|
|
echo "$category_kws"
|
|
;;
|
|
*)
|
|
# Default: keep all category keywords
|
|
echo "$category_kws"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Function: Generate keywords from skill name (no duplicates)
|
|
# -----------------------------------------------------------------------------
|
|
generate_name_keywords() {
|
|
local skill_name="$1"
|
|
local keywords="$skill_name"
|
|
|
|
# Add individual parts longer than 2 chars
|
|
IFS='-' read -ra parts <<< "$skill_name"
|
|
for part in "${parts[@]}"; do
|
|
if [ ${#part} -gt 2 ]; then
|
|
keywords="$keywords,$part"
|
|
fi
|
|
done
|
|
|
|
# Note: Space-separated pairs (e.g., "openai agents") removed to avoid
|
|
# duplicating the hyphenated name (e.g., "openai-agents")
|
|
|
|
echo "$keywords"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Function: Extract keywords from description
|
|
# -----------------------------------------------------------------------------
|
|
extract_description_keywords() {
|
|
local description="$1"
|
|
local keywords=""
|
|
|
|
# Extract capitalized technical terms (REST, GraphQL, JWT, etc.)
|
|
local caps_terms=$(echo "$description" | grep -oE '\b[A-Z][A-Z0-9]+\b' 2>/dev/null | sort -u | tr '\n' ',' | tr '[:upper:]' '[:lower:]')
|
|
keywords="$caps_terms"
|
|
|
|
# Extract CamelCase terms
|
|
local camel_terms=$(echo "$description" | grep -oE '\b[A-Z][a-z]+[A-Z][a-zA-Z]*\b' 2>/dev/null | sort -u | tr '\n' ',' | tr '[:upper:]' '[:lower:]')
|
|
keywords="$keywords$camel_terms"
|
|
|
|
# Extract valuable technical terms
|
|
local valuable_terms="scalable maintainable production authentication authorization validation optimization integration implementation configuration deployment migration refactoring edge caching streaming realtime real-time webhooks async asynchronous"
|
|
|
|
for term in $valuable_terms; do
|
|
if echo "$description" | grep -qi "\b$term"; then
|
|
keywords="$keywords,$term"
|
|
fi
|
|
done
|
|
|
|
echo "$keywords"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Function: Extract keywords from YAML frontmatter in SKILL.md
|
|
# -----------------------------------------------------------------------------
|
|
extract_yaml_keywords() {
|
|
local skill_md="$1"
|
|
local keywords=""
|
|
|
|
if [ ! -f "$skill_md" ]; then
|
|
echo ""
|
|
return
|
|
fi
|
|
|
|
# Extract keywords from YAML frontmatter
|
|
# Handles both top-level keywords and nested (under metadata)
|
|
keywords=$(awk '
|
|
BEGIN {in_frontmatter=0; in_keywords=0; indent_level=0}
|
|
/^---$/ {
|
|
frontmatter_count++
|
|
if (frontmatter_count==1) {in_frontmatter=1; next}
|
|
if (frontmatter_count==2) {exit}
|
|
}
|
|
# Match both top-level keywords: and nested keywords:
|
|
in_frontmatter && /keywords:/ {
|
|
in_keywords=1
|
|
# Detect indentation level
|
|
match($0, /^[[:space:]]*/)
|
|
indent_level = RLENGTH
|
|
next
|
|
}
|
|
# Exit keywords section when we hit another key at same or lower indentation
|
|
in_frontmatter && in_keywords && /^[[:space:]]*[a-z-]+:/ {
|
|
match($0, /^[[:space:]]*/)
|
|
if (RLENGTH <= indent_level) {
|
|
in_keywords=0
|
|
}
|
|
}
|
|
# Extract keyword list items (with proper indentation)
|
|
in_frontmatter && in_keywords && /[[:space:]]*- / {
|
|
gsub(/^[[:space:]]*- /, "")
|
|
gsub(/^"/, "")
|
|
gsub(/"$/, "")
|
|
# Skip comment lines
|
|
if ($0 !~ /^#/) {
|
|
print
|
|
}
|
|
}
|
|
' "$skill_md" | grep -v '^$' | tr '\n' ',' | sed 's/,$//')
|
|
|
|
echo "$keywords"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Function: Extract explicit keywords from "Keywords: ..." line in description
|
|
# -----------------------------------------------------------------------------
|
|
extract_explicit_keywords() {
|
|
local description="$1"
|
|
local skill_md="$2"
|
|
local keywords=""
|
|
|
|
# PRIORITY 1: Extract from YAML frontmatter in SKILL.md
|
|
if [ -n "$skill_md" ]; then
|
|
keywords=$(extract_yaml_keywords "$skill_md")
|
|
fi
|
|
|
|
# PRIORITY 2: Fallback to "Keywords: ..." line in description (legacy support)
|
|
if [ -z "$keywords" ] && echo "$description" | grep -q "Keywords:"; then
|
|
# Extract everything after "Keywords:" until end of line/description
|
|
# Strip surrounding quotes from each keyword
|
|
keywords=$(echo "$description" | sed -n 's/.*Keywords:\s*\(.*\)/\1/p' | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//; s/^"//; s/"$//' | grep -v '^$' | tr '\n' ',')
|
|
fi
|
|
|
|
echo "$keywords"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Function: Remove "Keywords: ..." line from description
|
|
# -----------------------------------------------------------------------------
|
|
remove_keywords_line() {
|
|
local description="$1"
|
|
|
|
# Remove "Keywords: ..." portion from description
|
|
echo "$description" | sed 's/Keywords:.*$//' | sed 's/[[:space:]]*$//'
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Function: Generate keywords JSON array with deduplication
|
|
# -----------------------------------------------------------------------------
|
|
generate_keywords_json() {
|
|
local skill_name="$1"
|
|
local category="$2"
|
|
local description="$3"
|
|
local skill_md="$4"
|
|
|
|
# Collect from all sources
|
|
# PRIORITY ORDER: 1) Explicit keywords from SKILL.md, 2) Name, 3) Category, 4) Auto-extracted
|
|
local explicit_kw=$(extract_explicit_keywords "$description" "$skill_md")
|
|
local name_kw=$(generate_name_keywords "$skill_name")
|
|
local cat_kw=$(get_category_keywords "$category")
|
|
cat_kw=$(filter_category_keywords "$skill_name" "$category" "$cat_kw")
|
|
local desc_kw=$(extract_description_keywords "$description")
|
|
|
|
# If explicit keywords exist, prioritize them over category defaults
|
|
if [ -n "$explicit_kw" ]; then
|
|
local all_kw="$name_kw,$explicit_kw,$desc_kw"
|
|
else
|
|
local all_kw="$name_kw,$cat_kw,$desc_kw"
|
|
fi
|
|
|
|
# Convert to JSON array with deduplication
|
|
local deduplicated_keywords=()
|
|
local seen=""
|
|
|
|
IFS=',' read -ra keywords <<< "$all_kw"
|
|
for kw in "${keywords[@]}"; do
|
|
kw=$(echo "$kw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]')
|
|
if [ -z "$kw" ] || [ ${#kw} -le 2 ] || [[ "$seen" == *"|$kw|"* ]]; then
|
|
continue
|
|
fi
|
|
|
|
# Skip category-redundant keywords
|
|
if [[ "$category" == "cloudflare" && "$kw" == "serverless" ]]; then
|
|
continue # "serverless" is implied by "workers" + "edge"
|
|
fi
|
|
|
|
# Skip generic keywords for specific skills
|
|
if [[ "$skill_name" == "nuxt-seo" && "$kw" == "configuration" ]]; then
|
|
continue # Too generic - "configuration" doesn't distinguish SEO functionality
|
|
fi
|
|
|
|
# Global generic keyword filter (all skills)
|
|
case "$kw" in
|
|
# Programming languages (standalone - not compounded)
|
|
typescript|javascript|python|go|rust)
|
|
continue ;;
|
|
# Generic deployment/process terms
|
|
deployment|production|development)
|
|
continue ;;
|
|
# Directory names (too descriptive, not searchable)
|
|
"server directory"|"public directory"|"assets directory"|"app directory"|"components directory")
|
|
continue ;;
|
|
# Generic single-word terms (too broad, hurt search relevance)
|
|
api|beta|status|integration|first|design|app|correct|not|streaming|sdk)
|
|
# Keep if it's part of the skill name (e.g., "api" ok for "openai-api")
|
|
if [[ ! "$skill_name" =~ $kw ]]; then
|
|
continue
|
|
fi
|
|
;;
|
|
# Standalone generic terms (only skip if not part of skill name)
|
|
modules|plugins|layers|middleware|configuration|optimization)
|
|
# Keep if it's part of the skill name (e.g., "middleware" skill keeps "middleware" keyword)
|
|
if [[ ! "$skill_name" =~ $kw ]]; then
|
|
continue
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
seen="$seen|$kw|"
|
|
deduplicated_keywords+=("$kw")
|
|
done
|
|
|
|
# Build JSON array using jq (proper escaping)
|
|
local json_array=$(printf '%s\n' "${deduplicated_keywords[@]}" | jq -R . | jq -s .)
|
|
|
|
# Validation: Warn if keyword count is outside optimal range
|
|
keyword_count=$(echo "$json_array" | jq '. | length')
|
|
if [ "$keyword_count" -lt 15 ]; then
|
|
echo " ⚠️ Warning: $skill_name has only $keyword_count keywords (recommend 15-35)" >&2
|
|
elif [ "$keyword_count" -gt 35 ]; then
|
|
echo " ⚠️ Warning: $skill_name has $keyword_count keywords (recommend 15-35 for search relevance)" >&2
|
|
fi
|
|
|
|
echo "$json_array"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Function: Scan for agents (agents/*.md files)
|
|
# -----------------------------------------------------------------------------
|
|
scan_agents() {
|
|
local skill_dir="$1"
|
|
local agents_dir="$skill_dir/agents"
|
|
local json_array=""
|
|
|
|
if [ -d "$agents_dir" ]; then
|
|
local agents=$(find "$agents_dir" -name "*.md" -type f 2>/dev/null | sort)
|
|
if [ -n "$agents" ]; then
|
|
json_array="["
|
|
local first=true
|
|
while IFS= read -r agent_file; do
|
|
local relative_path="./agents/$(basename "$agent_file")"
|
|
if [ "$first" = true ]; then
|
|
first=false
|
|
else
|
|
json_array="$json_array,"
|
|
fi
|
|
json_array="$json_array\"$relative_path\""
|
|
done <<< "$agents"
|
|
json_array="$json_array]"
|
|
fi
|
|
fi
|
|
|
|
echo "$json_array"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Function: Scan for commands (commands/*.md files)
|
|
# -----------------------------------------------------------------------------
|
|
scan_commands() {
|
|
local skill_dir="$1"
|
|
local commands_dir="$skill_dir/commands"
|
|
local json_array=""
|
|
|
|
if [ -d "$commands_dir" ]; then
|
|
local commands=$(find "$commands_dir" -name "*.md" -type f 2>/dev/null | sort)
|
|
if [ -n "$commands" ]; then
|
|
json_array="["
|
|
local first=true
|
|
while IFS= read -r cmd_file; do
|
|
local relative_path="./commands/$(basename "$cmd_file")"
|
|
if [ "$first" = true ]; then
|
|
first=false
|
|
else
|
|
json_array="$json_array,"
|
|
fi
|
|
json_array="$json_array\"$relative_path\""
|
|
done <<< "$commands"
|
|
json_array="$json_array]"
|
|
fi
|
|
fi
|
|
|
|
echo "$json_array"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Main processing loop (flat structure)
|
|
# -----------------------------------------------------------------------------
|
|
count=0
|
|
updated=0
|
|
skipped=0
|
|
# Count total plugins
|
|
total=$(find "$PLUGINS_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')
|
|
|
|
echo "Processing skills from all plugins..."
|
|
echo ""
|
|
|
|
# Loop: iterate through plugin directories (flat structure)
|
|
while IFS= read -r plugin_dir_path; do
|
|
if [ ! -d "$plugin_dir_path" ]; then
|
|
continue
|
|
fi
|
|
|
|
skill_name=$(basename "$plugin_dir_path")
|
|
skill_md="$plugin_dir_path/SKILL.md"
|
|
plugin_dir="$plugin_dir_path/.claude-plugin"
|
|
plugin_json="$plugin_dir/plugin.json"
|
|
plugin_base_dir="$plugin_dir_path"
|
|
is_multi_skill=false
|
|
|
|
count=$((count + 1))
|
|
|
|
# Multi-skill plugins: no root SKILL.md, skills live at skills/*/SKILL.md
|
|
if [ ! -f "$skill_md" ]; then
|
|
sub_skill_count=$(find "$plugin_dir_path/skills" -mindepth 2 -maxdepth 2 \
|
|
-name "SKILL.md" -type f 2>/dev/null | wc -l | tr -d ' ')
|
|
if [ "$sub_skill_count" -gt 0 ]; then
|
|
is_multi_skill=true
|
|
skill_md=""
|
|
else
|
|
printf "[%3d/%d] %-40s SKIP (no SKILL.md)\n" "$count" "$total" "$skill_name"
|
|
skipped=$((skipped + 1))
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
# Create .claude-plugin directory if needed
|
|
if [ ! -d "$plugin_dir" ]; then
|
|
if [ "$DRY_RUN" = false ]; then
|
|
mkdir -p "$plugin_dir"
|
|
fi
|
|
fi
|
|
|
|
# Get current values or defaults
|
|
if [ -f "$plugin_json" ]; then
|
|
current_author=$(jq -c '.author // {"name": "Claude Skills Maintainers", "email": "maintainers@example.com"}' "$plugin_json" 2>/dev/null)
|
|
else
|
|
current_author='{"name": "Claude Skills Maintainers", "email": "maintainers@example.com"}'
|
|
fi
|
|
|
|
category=$(categorize_skill "$skill_name")
|
|
|
|
if [ "$is_multi_skill" = true ]; then
|
|
# Multi-skill plugins: preserve existing plugin.json description (manually curated)
|
|
current_desc=""
|
|
if [ -f "$plugin_json" ]; then
|
|
current_desc=$(jq -r '.description // ""' "$plugin_json" 2>/dev/null)
|
|
fi
|
|
if [ -z "$current_desc" ]; then
|
|
current_desc="Multi-skill plugin: $skill_name"
|
|
fi
|
|
# Pass empty skill_md so keyword generation doesn't try to read a file
|
|
keywords_json=$(generate_keywords_json "$skill_name" "$category" "$current_desc" "")
|
|
else
|
|
# Single-skill plugins: extract description from SKILL.md (source of truth)
|
|
# Only search within first YAML frontmatter block (between first two --- delimiters)
|
|
current_desc=$(awk '
|
|
BEGIN {in_frontmatter=0; found_desc=0}
|
|
/^---$/ {
|
|
frontmatter_count++
|
|
if (frontmatter_count==1) {in_frontmatter=1; next}
|
|
if (frontmatter_count==2) {exit}
|
|
}
|
|
in_frontmatter && /^description:/ {
|
|
if ($0 !~ /[\|>]$/) {
|
|
gsub(/^description: */, "")
|
|
print
|
|
exit
|
|
}
|
|
}
|
|
' "$skill_md")
|
|
if [ -z "$current_desc" ]; then
|
|
current_desc=$(awk '
|
|
BEGIN {in_frontmatter=0}
|
|
/^---$/ {
|
|
frontmatter_count++
|
|
if (frontmatter_count==1) {in_frontmatter=1; next}
|
|
if (frontmatter_count==2) {exit}
|
|
}
|
|
in_frontmatter && /^description: [\|>]/{flag=1; next}
|
|
in_frontmatter && /^[a-z-]+:/{flag=0}
|
|
flag && /^ /{gsub(/^ /, ""); line=line $0 " "}
|
|
END{gsub(/ $/, "", line); print line}
|
|
' "$skill_md")
|
|
fi
|
|
if [ -z "$current_desc" ] || [ "$current_desc" = "|" ] || [ "$current_desc" = ">" ]; then
|
|
current_desc="Production-ready skill for $skill_name"
|
|
fi
|
|
|
|
# Generate keywords (uses full description with Keywords line for extraction)
|
|
keywords_json=$(generate_keywords_json "$skill_name" "$category" "$current_desc" "$skill_md")
|
|
fi
|
|
|
|
# Remove "Keywords: ..." line from description for plugin.json output
|
|
clean_desc=$(remove_keywords_line "$current_desc")
|
|
|
|
# Validate description length
|
|
desc_length=${#clean_desc}
|
|
if [ "$is_multi_skill" = false ] && [ "$desc_length" -gt 250 ]; then
|
|
echo " ❌ ERROR: $skill_name description is $desc_length chars (max 250)" >&2
|
|
echo " Description: $clean_desc" >&2
|
|
echo " SKIPPING - Fix SKILL.md description first (condense to <250 chars)" >&2
|
|
continue # Skip this skill
|
|
elif [ "$desc_length" -gt 150 ]; then
|
|
echo " ⚠️ Warning: $skill_name description is $desc_length chars (recommend <150 for brevity)" >&2
|
|
fi
|
|
|
|
# Scan for agents and commands
|
|
agents_json=$(scan_agents "$plugin_base_dir")
|
|
commands_json=$(scan_commands "$plugin_base_dir")
|
|
|
|
# Build the updated plugin.json
|
|
if [ "$DRY_RUN" = false ]; then
|
|
if [ "$is_multi_skill" = true ] && [ -f "$plugin_json" ]; then
|
|
# Multi-skill plugins: merge updated fields into existing plugin.json
|
|
# This preserves extra fields like mcpServers, homepage that the template doesn't include
|
|
desc_json=$(echo "$clean_desc" | jq -Rs '. | rtrimstr("\n") | rtrimstr(" ") | rtrimstr("\n")')
|
|
|
|
# Build jq filter dynamically based on whether agents/commands exist
|
|
jq_filter='.name = $name | .description = $desc | .version = $version | .author = $author | .keywords = $keywords | del(.agents) | del(.commands) | del(.category)'
|
|
jq_args=("--arg" "name" "$skill_name" "--argjson" "desc" "$desc_json" "--arg" "version" "$GLOBAL_VERSION" "--argjson" "author" "$current_author" "--argjson" "keywords" "$keywords_json")
|
|
|
|
if [ -n "$agents_json" ]; then
|
|
jq_filter="$jq_filter | .agents = \$agents"
|
|
jq_args+=("--argjson" "agents" "$agents_json")
|
|
fi
|
|
if [ -n "$commands_json" ]; then
|
|
jq_filter="$jq_filter | .commands = \$commands"
|
|
jq_args+=("--argjson" "commands" "$commands_json")
|
|
fi
|
|
|
|
if jq "$jq_filter" "${jq_args[@]}" "$plugin_json" > "$plugin_json.tmp" 2>/dev/null; then
|
|
jq '.' "$plugin_json.tmp" > "$plugin_json"
|
|
rm "$plugin_json.tmp"
|
|
else
|
|
echo " ❌ ERROR: Invalid JSON generated for $skill_name"
|
|
rm -f "$plugin_json.tmp"
|
|
continue
|
|
fi
|
|
elif [ "$is_multi_skill" = true ]; then
|
|
# Multi-skill plugins: first run (no existing plugin.json)
|
|
desc_json=$(echo "$clean_desc" | jq -Rs '. | rtrimstr("\n") | rtrimstr(" ") | rtrimstr("\n")')
|
|
cat > "$plugin_json.tmp" << EOF
|
|
{
|
|
"name": "$skill_name",
|
|
"description": $desc_json,
|
|
"version": "$GLOBAL_VERSION",
|
|
"author": $current_author,
|
|
"license": "MIT",
|
|
"repository": "https://github.com/secondsky/claude-skills",
|
|
"keywords": $keywords_json
|
|
EOF
|
|
|
|
if [ -n "$agents_json" ]; then
|
|
echo ", \"agents\": $agents_json" >> "$plugin_json.tmp"
|
|
fi
|
|
|
|
if [ -n "$commands_json" ]; then
|
|
echo ", \"commands\": $commands_json" >> "$plugin_json.tmp"
|
|
fi
|
|
|
|
echo "}" >> "$plugin_json.tmp"
|
|
|
|
if jq '.' "$plugin_json.tmp" > /dev/null 2>&1; then
|
|
jq '.' "$plugin_json.tmp" > "$plugin_json"
|
|
rm "$plugin_json.tmp"
|
|
else
|
|
echo " ❌ ERROR: Invalid JSON generated for $skill_name"
|
|
rm -f "$plugin_json.tmp"
|
|
continue
|
|
fi
|
|
elif [ -f "$plugin_json" ]; then
|
|
# Single-skill with existing plugin.json: merge to preserve extra fields (homepage, skills, etc.)
|
|
desc_json=$(echo "$clean_desc" | jq -Rs '. | rtrimstr("\n") | rtrimstr(" ") | rtrimstr("\n")')
|
|
|
|
jq_filter='.name = $name | .description = $desc | .version = $version | .author = $author | .license = $license | .repository = $repo | .keywords = $keywords | del(.agents) | del(.commands) | del(.category)'
|
|
jq_args=("--arg" "name" "$skill_name" "--argjson" "desc" "$desc_json" "--arg" "version" "$GLOBAL_VERSION" "--argjson" "author" "$current_author" "--arg" "license" "MIT" "--arg" "repo" "https://github.com/secondsky/claude-skills" "--argjson" "keywords" "$keywords_json")
|
|
|
|
if [ -n "$agents_json" ]; then
|
|
jq_filter="$jq_filter | .agents = \$agents"
|
|
jq_args+=("--argjson" "agents" "$agents_json")
|
|
fi
|
|
if [ -n "$commands_json" ]; then
|
|
jq_filter="$jq_filter | .commands = \$commands"
|
|
jq_args+=("--argjson" "commands" "$commands_json")
|
|
fi
|
|
|
|
if jq "$jq_filter" "${jq_args[@]}" "$plugin_json" > "$plugin_json.tmp" 2>/dev/null; then
|
|
jq '.' "$plugin_json.tmp" > "$plugin_json"
|
|
rm "$plugin_json.tmp"
|
|
else
|
|
echo " ❌ ERROR: Invalid JSON generated for $skill_name"
|
|
rm -f "$plugin_json.tmp"
|
|
continue
|
|
fi
|
|
else
|
|
# No existing plugin.json: create from template
|
|
desc_json=$(echo "$clean_desc" | jq -Rs '. | rtrimstr("\n") | rtrimstr(" ") | rtrimstr("\n")')
|
|
cat > "$plugin_json.tmp" << EOF
|
|
{
|
|
"name": "$skill_name",
|
|
"description": $desc_json,
|
|
"version": "$GLOBAL_VERSION",
|
|
"author": $current_author,
|
|
"license": "MIT",
|
|
"repository": "https://github.com/secondsky/claude-skills",
|
|
"keywords": $keywords_json
|
|
EOF
|
|
|
|
if [ -n "$agents_json" ]; then
|
|
echo ", \"agents\": $agents_json" >> "$plugin_json.tmp"
|
|
fi
|
|
|
|
if [ -n "$commands_json" ]; then
|
|
echo ", \"commands\": $commands_json" >> "$plugin_json.tmp"
|
|
fi
|
|
|
|
echo "}" >> "$plugin_json.tmp"
|
|
|
|
if jq '.' "$plugin_json.tmp" > /dev/null 2>&1; then
|
|
jq '.' "$plugin_json.tmp" > "$plugin_json"
|
|
rm "$plugin_json.tmp"
|
|
else
|
|
echo " ❌ ERROR: Invalid JSON generated for $skill_name"
|
|
rm "$plugin_json.tmp"
|
|
continue
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Output status
|
|
extras=""
|
|
if [ -n "$agents_json" ]; then
|
|
agent_count=$(echo "$agents_json" | jq 'length')
|
|
extras="$extras +${agent_count}agents"
|
|
fi
|
|
if [ -n "$commands_json" ]; then
|
|
cmd_count=$(echo "$commands_json" | jq 'length')
|
|
extras="$extras +${cmd_count}cmds"
|
|
fi
|
|
|
|
if [ "$DRY_RUN" = true ]; then
|
|
printf "[%3d/%d] %-40s → %s%s (dry run)\n" "$count" "$total" "$skill_name" "$category" "$extras"
|
|
else
|
|
printf "[%3d/%d] %-40s → %s%s\n" "$count" "$total" "$skill_name" "$category" "$extras"
|
|
fi
|
|
|
|
updated=$((updated + 1))
|
|
done < <(find "$PLUGINS_DIR" -mindepth 1 -maxdepth 1 -type d | sort)
|
|
|
|
echo ""
|
|
echo "============================================"
|
|
echo "Plugin Sync Complete"
|
|
echo "============================================"
|
|
echo ""
|
|
echo "Plugins processed: $count"
|
|
echo "Plugins updated: $updated"
|
|
echo "Plugins skipped: $skipped"
|
|
echo "Global version: $GLOBAL_VERSION"
|
|
echo ""
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Stamp .version into .codex-plugin/plugin.json to match the Claude manifests.
|
|
# NOTE: this only synchronizes the version field. The full codex manifest
|
|
# (description, keywords, interface, etc.) is regenerated downstream by
|
|
# generate-codex-manifests.sh (invoked via generate-marketplace.sh below), which
|
|
# treats the Claude manifests as the source of truth. Do not expect this loop
|
|
# alone to keep codex fields in sync — it is a version lockstep only.
|
|
# -----------------------------------------------------------------------------
|
|
codex_count=0
|
|
codex_updated=0
|
|
while IFS= read -r codex_json; do
|
|
if [ ! -f "$codex_json" ]; then
|
|
continue
|
|
fi
|
|
codex_count=$((codex_count + 1))
|
|
if [ "$DRY_RUN" = false ]; then
|
|
# Write to .tmp, VALIDATE with jq, then promote — all in one `&&` chain so
|
|
# that a failure at ANY step (jq write, jq validate, OR mv) routes to the
|
|
# else branch for cleanup. Previously `mv` was a standalone command inside
|
|
# `then`, so an mv failure aborted under set -e instead of being reported.
|
|
if jq --arg v "$GLOBAL_VERSION" '.version = $v' "$codex_json" > "$codex_json.tmp" \
|
|
&& jq '.' "$codex_json.tmp" > /dev/null 2>&1 \
|
|
&& mv "$codex_json.tmp" "$codex_json"; then
|
|
codex_updated=$((codex_updated + 1))
|
|
else
|
|
rm -f "$codex_json.tmp"
|
|
echo "⚠️ Failed to sync version for $(basename "$(dirname "$(dirname "$codex_json")")") — left unchanged" >&2
|
|
fi
|
|
fi
|
|
done < <(find "$PLUGINS_DIR" -path '*/.codex-plugin/plugin.json' | sort)
|
|
|
|
if [ "$codex_count" -gt 0 ]; then
|
|
echo "Codex version synced: $codex_updated/$codex_count"
|
|
fi
|
|
echo ""
|
|
|
|
if [ "$DRY_RUN" = false ]; then
|
|
# Regenerate Claude marketplace.json
|
|
echo "Regenerating Claude marketplace.json..."
|
|
if ! "$SCRIPT_DIR/generate-marketplace.sh"; then
|
|
echo "❌ ERROR: Failed to regenerate marketplace.json" >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "Dry run complete. No files were modified."
|
|
echo "Run without --dry-run to apply changes."
|
|
fi
|