feat: consolidate marketplace skills into kyberforge plugin
Moves create-plugin, marketplace-architect, write-skill, and write-eval from canonical .agents/skills/ into plugins/kyberforge/skills/, along with all bundled sub-files, evals, and the plugin-marketplace-architecture research doc. Bundles templates/plugin/ into create-plugin/assets/plugin-template/ so the skill is self-contained after install-time caching. Removes templates/plugin/ and docs/research/plugin-marketplace-architecture.md from the repo root as they are now exclusively in the plugin. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
194
plugins/kyberforge/skills/marketplace-architect/scripts/gen_manifests.sh
Executable file
194
plugins/kyberforge/skills/marketplace-architect/scripts/gen_manifests.sh
Executable file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate plugin.json (in both locations) and marketplace.json.
|
||||
# Dry-run by default; pass --write to apply.
|
||||
#
|
||||
# Usage:
|
||||
# gen_manifests.sh <repo-root> --marketplace-name <name> [options]
|
||||
#
|
||||
# Options:
|
||||
# --marketplace-name <name> kebab-case marketplace identifier (required)
|
||||
# --author "Name <email>" author string (default: "Unknown <unknown@example.com>")
|
||||
# --plugins-dir <dir> subdir containing plugin folders (default: plugins)
|
||||
# --mirror-github also write to .github/plugin/marketplace.json
|
||||
# --write apply changes (default is dry run)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RESERVED_NAMES="claude-code-marketplace claude-code-plugins claude-plugins-official
|
||||
claude-plugins-community claude-community anthropic-marketplace anthropic-plugins
|
||||
agent-skills anthropic-agent-skills knowledge-work-plugins life-sciences
|
||||
claude-for-legal claude-for-financial-services financial-services-plugins"
|
||||
|
||||
RESERVED_PATTERNS="official-claude anthropic-tools claude-official"
|
||||
|
||||
is_kebab_case() {
|
||||
[[ "$1" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]
|
||||
}
|
||||
|
||||
is_reserved() {
|
||||
local name="$1"
|
||||
for n in $RESERVED_NAMES; do
|
||||
[[ "$name" == "$n" ]] && return 0
|
||||
done
|
||||
for p in $RESERVED_PATTERNS; do
|
||||
[[ "$name" == "$p"* ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
validate_name() {
|
||||
local name="$1" context="$2"
|
||||
local ok=true
|
||||
if ! is_kebab_case "$name"; then
|
||||
echo "ERROR: $context: name '$name' is not kebab-case (lowercase, digits, hyphens only)." >&2
|
||||
ok=false
|
||||
fi
|
||||
if is_reserved "$name"; then
|
||||
echo "ERROR: $context: name '$name' is reserved for official Anthropic use." >&2
|
||||
ok=false
|
||||
fi
|
||||
[[ "$ok" == "true" ]]
|
||||
}
|
||||
|
||||
write_json() {
|
||||
local path="$1" content="$2" dry_run="$3"
|
||||
if [[ "$dry_run" == "true" ]]; then
|
||||
echo ""
|
||||
echo "--- $path (dry run) ---"
|
||||
echo "$content"
|
||||
else
|
||||
mkdir -p "$(dirname "$path")"
|
||||
echo "$content" > "$path"
|
||||
echo " Written: $path"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── parse args ───────────────────────────────────────────────────────────────
|
||||
|
||||
ROOT=""
|
||||
MARKETPLACE_NAME=""
|
||||
AUTHOR="Unknown <unknown@example.com>"
|
||||
PLUGINS_DIR="plugins"
|
||||
MIRROR_GITHUB=false
|
||||
WRITE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--marketplace-name) MARKETPLACE_NAME="$2"; shift 2 ;;
|
||||
--author) AUTHOR="$2"; shift 2 ;;
|
||||
--plugins-dir) PLUGINS_DIR="$2"; shift 2 ;;
|
||||
--mirror-github) MIRROR_GITHUB=true; shift ;;
|
||||
--write) WRITE=true; shift ;;
|
||||
-*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
*) ROOT="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$ROOT" || -z "$MARKETPLACE_NAME" ]]; then
|
||||
echo "Usage: gen_manifests.sh <repo-root> --marketplace-name <name> [--write]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
DRY_RUN=$( [[ "$WRITE" == "true" ]] && echo "false" || echo "true" )
|
||||
|
||||
[[ "$DRY_RUN" == "true" ]] && echo "DRY RUN — pass --write to apply changes"
|
||||
|
||||
# Parse author
|
||||
AUTHOR_NAME="${AUTHOR%% <*}"
|
||||
AUTHOR_EMAIL=""
|
||||
if [[ "$AUTHOR" =~ \<(.+)\> ]]; then
|
||||
AUTHOR_EMAIL="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
# Validate marketplace name
|
||||
validate_name "$MARKETPLACE_NAME" "marketplace" || exit 1
|
||||
|
||||
# Discover plugins
|
||||
PLUGINS_PATH="$ROOT/$PLUGINS_DIR"
|
||||
if [[ ! -d "$PLUGINS_PATH" ]]; then
|
||||
echo "No plugins directory found at $PLUGINS_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t PLUGIN_DIRS < <(find "$PLUGINS_PATH" -mindepth 1 -maxdepth 1 -type d ! -name '.*' | sort)
|
||||
|
||||
if [[ ${#PLUGIN_DIRS[@]} -eq 0 ]]; then
|
||||
echo "No plugin directories found in $PLUGINS_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found ${#PLUGIN_DIRS[@]} plugin(s)"
|
||||
|
||||
# Build plugins array for marketplace.json
|
||||
PLUGINS_JSON="[]"
|
||||
|
||||
for pd in "${PLUGIN_DIRS[@]}"; do
|
||||
pname="$(basename "$pd")"
|
||||
validate_name "$pname" "plugin '$pname'" || exit 1
|
||||
|
||||
# Read existing plugin.json if present
|
||||
existing_claude="$pd/.claude-plugin/plugin.json"
|
||||
existing_root="$pd/plugin.json"
|
||||
existing_desc="Plugin: $pname"
|
||||
existing_name="$pname"
|
||||
|
||||
for existing in "$existing_claude" "$existing_root"; do
|
||||
if [[ -f "$existing" ]] && jq -e . "$existing" >/dev/null 2>&1; then
|
||||
d=$(jq -r '.description // empty' "$existing")
|
||||
n=$(jq -r '.name // empty' "$existing")
|
||||
[[ -n "$d" ]] && existing_desc="$d"
|
||||
[[ -n "$n" ]] && existing_name="$n"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Warn on version
|
||||
for existing in "$existing_claude" "$existing_root"; do
|
||||
if [[ -f "$existing" ]] && jq -e '.version' "$existing" >/dev/null 2>&1; then
|
||||
echo " WARNING: plugin '$pname' sets version in plugin.json. Do not also set it in the marketplace entry — plugin.json wins silently."
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Build plugin.json
|
||||
plugin_json=$(jq -n \
|
||||
--arg name "$existing_name" \
|
||||
--arg desc "$existing_desc" \
|
||||
--arg aname "$AUTHOR_NAME" \
|
||||
--arg aemail "$AUTHOR_EMAIL" \
|
||||
'{name: $name, description: $desc, author: {name: $aname, email: $aemail}}')
|
||||
|
||||
write_json "$pd/plugin.json" "$plugin_json" "$DRY_RUN"
|
||||
write_json "$pd/.claude-plugin/plugin.json" "$plugin_json" "$DRY_RUN"
|
||||
|
||||
source="./$PLUGINS_DIR/$pname"
|
||||
PLUGINS_JSON=$(echo "$PLUGINS_JSON" | jq \
|
||||
--arg name "$existing_name" \
|
||||
--arg src "$source" \
|
||||
--arg desc "$existing_desc" \
|
||||
'. + [{name: $name, source: $src, description: $desc}]')
|
||||
done
|
||||
|
||||
# Build marketplace.json
|
||||
marketplace_json=$(jq -n \
|
||||
--arg name "$MARKETPLACE_NAME" \
|
||||
--arg aname "$AUTHOR_NAME" \
|
||||
--arg aemail "$AUTHOR_EMAIL" \
|
||||
--arg desc "$MARKETPLACE_NAME plugin marketplace" \
|
||||
--argjson plugins "$PLUGINS_JSON" \
|
||||
'{name: $name, owner: {name: $aname, email: $aemail}, description: $desc, plugins: $plugins}')
|
||||
|
||||
write_json "$ROOT/.claude-plugin/marketplace.json" "$marketplace_json" "$DRY_RUN"
|
||||
|
||||
if [[ "$MIRROR_GITHUB" == "true" ]]; then
|
||||
write_json "$ROOT/.github/plugin/marketplace.json" "$marketplace_json" "$DRY_RUN"
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo ""
|
||||
echo "--- End dry run. Pass --write to apply. ---"
|
||||
else
|
||||
echo ""
|
||||
echo "Done. Run scripts/validate.sh to verify."
|
||||
fi
|
||||
121
plugins/kyberforge/skills/marketplace-architect/scripts/inventory.sh
Executable file
121
plugins/kyberforge/skills/marketplace-architect/scripts/inventory.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scan a repository and classify every asset as skill/command/agent/hook/prompt/MCP.
|
||||
# Outputs a markdown table of findings plus a list of cross-reference warnings.
|
||||
#
|
||||
# Usage: inventory.sh <repo-path>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: inventory.sh <repo-path>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT="$(cd "$1" && pwd)"
|
||||
|
||||
if [[ ! -d "$ROOT" ]]; then
|
||||
echo "Error: $ROOT is not a directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── classify assets ──────────────────────────────────────────────────────────
|
||||
|
||||
declare -a ROWS=()
|
||||
declare -a CROSS_REFS=()
|
||||
|
||||
while IFS= read -r -d '' path; do
|
||||
rel="${path#"$ROOT/"}"
|
||||
name="$(basename "$path")"
|
||||
dir="$(dirname "$rel")"
|
||||
parent="$(basename "$dir")"
|
||||
|
||||
# Skip hidden dirs except .claude-plugin and .github
|
||||
skip=false
|
||||
IFS='/' read -ra parts <<< "$dir"
|
||||
for part in "${parts[@]}"; do
|
||||
if [[ "$part" == .* && "$part" != ".claude-plugin" && "$part" != ".github" && "$part" != ".agents" ]]; then
|
||||
skip=true; break
|
||||
fi
|
||||
done
|
||||
$skip && continue
|
||||
|
||||
asset_type=""
|
||||
|
||||
case "$name" in
|
||||
SKILL.md) asset_type="skill" ;;
|
||||
hooks.json) asset_type="hook" ;;
|
||||
.mcp.json) asset_type="mcp" ;;
|
||||
.lsp.json) asset_type="lsp" ;;
|
||||
plugin.json) asset_type="manifest-plugin" ;;
|
||||
marketplace.json) asset_type="manifest-marketplace" ;;
|
||||
*.agent.md) asset_type="agent-copilot" ;;
|
||||
*.md)
|
||||
if [[ "$parent" == "agents" ]]; then
|
||||
asset_type="agent-claude"
|
||||
elif [[ "$parent" == "commands" ]]; then
|
||||
asset_type="command"
|
||||
elif [[ "$rel" != *"/skills/"* && "$rel" != *"/commands/"* && "$rel" != *"/agents/"* ]]; then
|
||||
asset_type="prompt"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ -n "$asset_type" ]] && ROWS+=("$asset_type|$rel")
|
||||
|
||||
# Check for cross-references in text files
|
||||
case "$name" in *.md|*.json|*.sh)
|
||||
if grep -q '\.\.\/' "$path" 2>/dev/null; then
|
||||
while IFS= read -r line; do
|
||||
lineno="${line%%:*}"
|
||||
content="${line#*:}"
|
||||
CROSS_REFS+=("$rel:$lineno: $content")
|
||||
done < <(grep -n '\.\.\/' "$path" 2>/dev/null | head -20)
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
done < <(find "$ROOT" -type f -print0 | sort -z)
|
||||
|
||||
# ── report ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "# Asset Inventory: $ROOT"
|
||||
echo ""
|
||||
echo "## Assets"
|
||||
echo ""
|
||||
echo "| Type | Path |"
|
||||
echo "|---|---|"
|
||||
for row in "${ROWS[@]+"${ROWS[@]}"}"; do
|
||||
type="${row%%|*}"
|
||||
path="${row#*|}"
|
||||
echo "| \`$type\` | \`$path\` |"
|
||||
done | sort
|
||||
|
||||
total="${#ROWS[@]}"
|
||||
echo ""
|
||||
echo "**Total: $total assets**"
|
||||
echo ""
|
||||
|
||||
# Summary by type
|
||||
echo "## Summary by type"
|
||||
echo ""
|
||||
for row in "${ROWS[@]+"${ROWS[@]}"}"; do
|
||||
echo "${row%%|*}"
|
||||
done | sort | uniq -c | while read -r count type; do
|
||||
echo "- \`$type\`: $count"
|
||||
done
|
||||
|
||||
# Cross-reference warnings
|
||||
echo ""
|
||||
if [[ ${#CROSS_REFS[@]} -gt 0 ]]; then
|
||||
echo "## ⚠️ Cross-reference warnings (${#CROSS_REFS[@]} found)"
|
||||
echo ""
|
||||
echo "These \`../\` references will break after install-time caching:"
|
||||
echo ""
|
||||
for ref in "${CROSS_REFS[@]}"; do
|
||||
echo "- \`$ref\`"
|
||||
done
|
||||
else
|
||||
echo "## Cross-references"
|
||||
echo ""
|
||||
echo "No \`../\` cross-references found. Safe to proceed with plugin boundaries."
|
||||
fi
|
||||
332
plugins/kyberforge/skills/marketplace-architect/scripts/validate.sh
Executable file
332
plugins/kyberforge/skills/marketplace-architect/scripts/validate.sh
Executable file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validate plugin marketplace manifests for Claude Code and GitHub Copilot CLI.
|
||||
# Wraps `claude plugin validate` (Claude-side) and runs manual checks (Copilot-side).
|
||||
#
|
||||
# Usage:
|
||||
# validate.sh <repo-root>
|
||||
# validate.sh <repo-root> --plugin plugins/my-plugin
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RESERVED_NAMES="claude-code-marketplace claude-code-plugins claude-plugins-official
|
||||
claude-plugins-community claude-community anthropic-marketplace anthropic-plugins
|
||||
agent-skills anthropic-agent-skills knowledge-work-plugins life-sciences
|
||||
claude-for-legal claude-for-financial-services financial-services-plugins"
|
||||
|
||||
RESERVED_PATTERNS="official-claude anthropic-tools claude-official"
|
||||
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
error() { echo "ERROR: $1"; ((ERRORS++)) || true; }
|
||||
warn() { echo "WARN: $1"; ((WARNINGS++)) || true; }
|
||||
|
||||
is_kebab_case() { [[ "$1" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; }
|
||||
|
||||
is_reserved() {
|
||||
local name="$1"
|
||||
for n in $RESERVED_NAMES; do [[ "$name" == "$n" ]] && return 0; done
|
||||
for p in $RESERVED_PATTERNS; do [[ "$name" == "$p"* ]] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
validate_name() {
|
||||
local name="$1" context="$2"
|
||||
[[ -z "$name" ]] && { error "$context: name is missing or empty"; return 0; }
|
||||
is_kebab_case "$name" || error "$context: name '$name' is not kebab-case"
|
||||
if is_reserved "$name"; then
|
||||
error "$context: name '$name' is reserved for official Anthropic use"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
valid_json() {
|
||||
local path="$1"
|
||||
if ! jq -e . "$path" >/dev/null 2>&1; then
|
||||
error "Invalid JSON in $path"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
validate_marketplace_json() {
|
||||
local path="$1"
|
||||
[[ -f "$path" ]] || return 0
|
||||
valid_json "$path" || return 0
|
||||
|
||||
local name
|
||||
name=$(jq -r '.name // empty' "$path")
|
||||
[[ -z "$name" ]] && error "$path: 'name' field is required" || validate_name "$name" "$path"
|
||||
|
||||
local plugins_type
|
||||
plugins_type=$(jq -r 'if .plugins | type == "array" then "ok" else "bad" end' "$path")
|
||||
if [[ "$plugins_type" != "ok" ]]; then
|
||||
error "$path: 'plugins' must be an array"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check each plugin entry
|
||||
local seen_names=()
|
||||
while IFS= read -r pname; do
|
||||
# Duplicate check
|
||||
for seen in "${seen_names[@]+"${seen_names[@]}"}"; do
|
||||
if [[ "$seen" == "$pname" ]]; then error "$path: duplicate plugin name '$pname'"; fi
|
||||
done
|
||||
seen_names+=("$pname")
|
||||
validate_name "$pname" "$path plugin '$pname'"
|
||||
|
||||
# Source path check
|
||||
local src
|
||||
src=$(jq -r --arg n "$pname" '.plugins[] | select(.name==$n) | .source // empty' "$path")
|
||||
if [[ -n "$src" && "$src" != ./* && "$src" != "github" && "$src" != "npm" && "$src" != "url" && "$src" != "git-subdir" ]]; then
|
||||
warn "$path plugin '$pname': relative source '$src' should start with './' for Claude Code compatibility"
|
||||
fi
|
||||
|
||||
# Version duplication warning
|
||||
local has_ver
|
||||
has_ver=$(jq -r --arg n "$pname" '.plugins[] | select(.name==$n) | .version // empty' "$path")
|
||||
if [[ -n "$has_ver" ]]; then
|
||||
warn "$path plugin '$pname': version set in marketplace entry. If also set in plugin.json, plugin.json wins silently."
|
||||
fi
|
||||
|
||||
done < <(jq -r '.plugins[].name // empty' "$path")
|
||||
return 0
|
||||
}
|
||||
|
||||
validate_plugin_json() {
|
||||
local path="$1" marketplace_json="${2:-}"
|
||||
[[ -f "$path" ]] || return 0
|
||||
valid_json "$path" || return 0
|
||||
|
||||
local name
|
||||
name=$(jq -r '.name // empty' "$path")
|
||||
if [[ -z "$name" ]]; then
|
||||
warn "$path: 'name' field missing (plugin dir name will be used)"
|
||||
else
|
||||
validate_name "$name" "$path"
|
||||
|
||||
# Version duplication check
|
||||
if [[ -n "$marketplace_json" && -f "$marketplace_json" ]]; then
|
||||
local pver mver
|
||||
pver=$(jq -r '.version // empty' "$path")
|
||||
mver=$(jq -r --arg n "$name" '.plugins[]? | select(.name==$n) | .version // empty' "$marketplace_json")
|
||||
if [[ -n "$pver" && -n "$mver" ]]; then
|
||||
error "$path: version '$pver' set in both plugin.json and marketplace entry — plugin.json wins silently. Remove one."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
validate_skill_md() {
|
||||
local path="$1"
|
||||
local content
|
||||
content=$(cat "$path")
|
||||
if [[ "$content" != ---* ]]; then
|
||||
warn "$path: SKILL.md has no YAML frontmatter"
|
||||
return
|
||||
fi
|
||||
if ! echo "$content" | awk 'NR>1 && /^---/' | grep -q '^---'; then
|
||||
error "$path: SKILL.md frontmatter not closed"
|
||||
return
|
||||
fi
|
||||
if ! echo "$content" | awk '/^---/{n++; if(n==2) exit} n==1' | grep -q 'description:'; then
|
||||
warn "$path: SKILL.md frontmatter missing 'description' field"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_plugin_dir() {
|
||||
local pd="$1" marketplace_json="${2:-}"
|
||||
|
||||
local claude_manifest="$pd/.claude-plugin/plugin.json"
|
||||
local root_manifest="$pd/plugin.json"
|
||||
|
||||
if [[ ! -f "$claude_manifest" && ! -f "$root_manifest" ]]; then
|
||||
warn "$pd: no plugin.json found (will auto-discover components)"
|
||||
else
|
||||
validate_plugin_json "$claude_manifest" "$marketplace_json"
|
||||
validate_plugin_json "$root_manifest" "$marketplace_json"
|
||||
|
||||
# Sync check — shared identity fields must match; component path fields legitimately diverge
|
||||
if [[ -f "$claude_manifest" && -f "$root_manifest" ]]; then
|
||||
local field cv rv
|
||||
for field in name description version license; do
|
||||
cv=$(jq -r ".$field // empty" "$claude_manifest")
|
||||
rv=$(jq -r ".$field // empty" "$root_manifest")
|
||||
if [[ ( -n "$cv" || -n "$rv" ) && "$cv" != "$rv" ]]; then
|
||||
error "$pd: '$field' differs between .claude-plugin/plugin.json ('$cv') and plugin.json ('$rv')"
|
||||
fi
|
||||
done
|
||||
cv=$(jq -r '.author.name // empty' "$claude_manifest")
|
||||
rv=$(jq -r '.author.name // empty' "$root_manifest")
|
||||
if [[ ( -n "$cv" || -n "$rv" ) && "$cv" != "$rv" ]]; then
|
||||
error "$pd: 'author.name' differs between .claude-plugin/plugin.json ('$cv') and plugin.json ('$rv')"
|
||||
fi
|
||||
cv=$(jq -r '.keywords // [] | sort | join(",")' "$claude_manifest")
|
||||
rv=$(jq -r '.keywords // [] | sort | join(",")' "$root_manifest")
|
||||
if [[ "$cv" != "$rv" ]]; then
|
||||
error "$pd: 'keywords' differs between .claude-plugin/plugin.json and plugin.json"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Components must not be inside .claude-plugin/
|
||||
for bad_dir in skills agents hooks commands; do
|
||||
if [[ -d "$pd/.claude-plugin/$bad_dir" ]]; then
|
||||
error "$pd/.claude-plugin/$bad_dir: only plugin.json belongs in .claude-plugin/; move $bad_dir/ to plugin root"
|
||||
fi
|
||||
done
|
||||
|
||||
# Validate SKILL.md files
|
||||
while IFS= read -r -d '' skill_md; do
|
||||
validate_skill_md "$skill_md"
|
||||
done < <(find "$pd" -name "SKILL.md" -print0 2>/dev/null)
|
||||
|
||||
# Cross-reference check
|
||||
while IFS= read -r -d '' f; do
|
||||
if grep -q '\.\.\/' "$f" 2>/dev/null; then
|
||||
local rel="${f#"$pd/"}"
|
||||
error "$rel: contains '../' reference — plugins cannot access files outside their directory after caching"
|
||||
fi
|
||||
done < <(find "$pd" \( -name "*.md" -o -name "*.json" \) -print0 2>/dev/null)
|
||||
}
|
||||
|
||||
run_claude_validate() {
|
||||
local path="$1"
|
||||
if command -v claude >/dev/null 2>&1; then
|
||||
if ! claude plugin validate "$path" 2>&1; then
|
||||
error "claude plugin validate failed for $path"
|
||||
fi
|
||||
else
|
||||
warn "'claude' CLI not found — skipping claude plugin validate"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── parse args ────────────────────────────────────────────────────────────────
|
||||
|
||||
ROOT=""
|
||||
PLUGIN_ONLY=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--plugin) PLUGIN_ONLY="$2"; shift 2 ;;
|
||||
-*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
*) ROOT="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$ROOT" ]]; then
|
||||
echo "Usage: validate.sh <repo-root> [--plugin <path>]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
|
||||
# ── validate marketplace.json ─────────────────────────────────────────────────
|
||||
|
||||
CLAUDE_MARKETPLACE="$ROOT/.claude-plugin/marketplace.json"
|
||||
COPILOT_MARKETPLACE="$ROOT/.github/plugin/marketplace.json"
|
||||
MARKETPLACE_JSON=""
|
||||
|
||||
for mp in "$CLAUDE_MARKETPLACE" "$COPILOT_MARKETPLACE"; do
|
||||
if [[ -f "$mp" ]]; then
|
||||
[[ -z "$MARKETPLACE_JSON" ]] && MARKETPLACE_JSON="$mp"
|
||||
validate_marketplace_json "$mp"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$MARKETPLACE_JSON" ]]; then
|
||||
warn "No marketplace.json found. Expected at .claude-plugin/marketplace.json"
|
||||
fi
|
||||
|
||||
# Marketplace sync check — shared identity fields must match; description/version
|
||||
# legitimately differ in structure (Claude: top-level; Copilot: under metadata)
|
||||
if [[ -f "$CLAUDE_MARKETPLACE" && -f "$COPILOT_MARKETPLACE" ]]; then
|
||||
cm_val=$(jq -r '.name // empty' "$CLAUDE_MARKETPLACE")
|
||||
cp_val=$(jq -r '.name // empty' "$COPILOT_MARKETPLACE")
|
||||
if [[ "$cm_val" != "$cp_val" ]]; then
|
||||
error "marketplace: 'name' differs — .claude-plugin ('$cm_val') vs .github/plugin ('$cp_val')"
|
||||
fi
|
||||
|
||||
cm_val=$(jq -r '.owner.name // empty' "$CLAUDE_MARKETPLACE")
|
||||
cp_val=$(jq -r '.owner.name // empty' "$COPILOT_MARKETPLACE")
|
||||
if [[ ( -n "$cm_val" || -n "$cp_val" ) && "$cm_val" != "$cp_val" ]]; then
|
||||
error "marketplace: 'owner.name' differs — '$cm_val' vs '$cp_val'"
|
||||
fi
|
||||
|
||||
# description: Claude top-level, Copilot under metadata — compare values regardless of path
|
||||
cm_val=$(jq -r '.description // .metadata.description // empty' "$CLAUDE_MARKETPLACE")
|
||||
cp_val=$(jq -r '.metadata.description // .description // empty' "$COPILOT_MARKETPLACE")
|
||||
if [[ ( -n "$cm_val" || -n "$cp_val" ) && "$cm_val" != "$cp_val" ]]; then
|
||||
error "marketplace: description differs between .claude-plugin/marketplace.json and .github/plugin/marketplace.json"
|
||||
fi
|
||||
|
||||
# version: same structural divergence as description
|
||||
cm_val=$(jq -r '.version // .metadata.version // empty' "$CLAUDE_MARKETPLACE")
|
||||
cp_val=$(jq -r '.metadata.version // .version // empty' "$COPILOT_MARKETPLACE")
|
||||
if [[ ( -n "$cm_val" || -n "$cp_val" ) && "$cm_val" != "$cp_val" ]]; then
|
||||
error "marketplace: version differs — '$cm_val' vs '$cp_val'"
|
||||
fi
|
||||
|
||||
# Plugin catalog must be identical across both files
|
||||
cm_plugins=$(jq -r '.plugins[].name' "$CLAUDE_MARKETPLACE" 2>/dev/null | sort)
|
||||
cp_plugins=$(jq -r '.plugins[].name' "$COPILOT_MARKETPLACE" 2>/dev/null | sort)
|
||||
if [[ "$cm_plugins" != "$cp_plugins" ]]; then
|
||||
error "marketplace: plugin lists differ between .claude-plugin/marketplace.json and .github/plugin/marketplace.json"
|
||||
else
|
||||
while IFS= read -r pname; do
|
||||
[[ -z "$pname" ]] && continue
|
||||
cm_val=$(jq -r --arg n "$pname" '.plugins[] | select(.name==$n) | .source // empty' "$CLAUDE_MARKETPLACE")
|
||||
cp_val=$(jq -r --arg n "$pname" '.plugins[] | select(.name==$n) | .source // empty' "$COPILOT_MARKETPLACE")
|
||||
if [[ "$cm_val" != "$cp_val" ]]; then
|
||||
error "marketplace plugin '$pname': source differs — '$cm_val' vs '$cp_val'"
|
||||
fi
|
||||
cm_val=$(jq -r --arg n "$pname" '.plugins[] | select(.name==$n) | .description // empty' "$CLAUDE_MARKETPLACE")
|
||||
cp_val=$(jq -r --arg n "$pname" '.plugins[] | select(.name==$n) | .description // empty' "$COPILOT_MARKETPLACE")
|
||||
if [[ ( -n "$cm_val" || -n "$cp_val" ) && "$cm_val" != "$cp_val" ]]; then
|
||||
error "marketplace plugin '$pname': description differs between the two marketplace.json files"
|
||||
fi
|
||||
done <<< "$cm_plugins"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── validate plugins ──────────────────────────────────────────────────────────
|
||||
|
||||
if [[ -n "$PLUGIN_ONLY" ]]; then
|
||||
validate_plugin_dir "$(cd "$PLUGIN_ONLY" && pwd)" "$MARKETPLACE_JSON"
|
||||
run_claude_validate "$(cd "$PLUGIN_ONLY" && pwd)"
|
||||
else
|
||||
plugins_path="$ROOT/plugins"
|
||||
if [[ -d "$plugins_path" ]]; then
|
||||
while IFS= read -r -d '' pd; do
|
||||
validate_plugin_dir "$pd" "$MARKETPLACE_JSON"
|
||||
run_claude_validate "$pd"
|
||||
done < <(find "$plugins_path" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print0 | sort -z)
|
||||
else
|
||||
warn "No plugins/ directory found at $ROOT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── check source paths resolve ────────────────────────────────────────────────
|
||||
|
||||
if [[ -n "$MARKETPLACE_JSON" ]]; then
|
||||
while IFS= read -r src; do
|
||||
[[ "$src" != ./* ]] && continue
|
||||
src_path="$ROOT/${src#./}"
|
||||
if [[ ! -d "$src_path" ]]; then error "Marketplace source path '$src' does not exist at $src_path"; fi
|
||||
done < <(jq -r '.plugins[]?.source | strings' "$MARKETPLACE_JSON" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# ── report ────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
if [[ $ERRORS -eq 0 && $WARNINGS -eq 0 ]]; then
|
||||
echo "✓ All checks passed."
|
||||
exit 0
|
||||
elif [[ $ERRORS -eq 0 ]]; then
|
||||
echo "Passed with $WARNINGS warning(s)."
|
||||
exit 0
|
||||
else
|
||||
echo "Failed. Fix $ERRORS error(s) before proceeding."
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user