mirror of
https://github.com/secondsky/claude-skills.git
synced 2026-09-18 19:54:22 +08:00
fb6e3f1c2b
* fix(scripts): adversarial audit fixes across generators, validators, review tooling
Round-1 adversarial subagent review found 13 confirmed bugs across the
plugin-marketplace scripts. All verified fixed:
Critical:
- review-skill.sh: ((VAR++)) returned 1 under set -e when counter was 0,
aborting the script on the first finding and making the entire report
unreachable for any non-perfect skill. Add || true.
- review-skill.sh: date -d is GNU-only; on macOS every skill with
last_verified was falsely flagged as ~20500 days stale. Use BSD date -jf
with GNU fallback.
Important:
- validate-frontmatter.sh: set -e + validate_skill '$file' aborted on the
first failing skill; summary never printed. Guard with || true
(CRITICAL_COUNT already tracks failures).
- validate-json-schemas.sh: plugin_count=1 when zero files found
(echo '' | wc -l); report falsely showed 'Total: 1'. Explicit empty guard.
- generate-marketplace.sh: version sync read .plugins[0] (always the
alphabetically-first plugin), masking drift instead of detecting it.
Now asserts all plugin versions agree; fails loudly on drift.
- baseline-audit-all.sh: wrote to planning/ which doesn't exist; died before
processing any skill. mkdir -p the output dir.
- check-versions.sh: head -n -1 is GNU-only; silently zeroed the dep list
on macOS. Use portable sed '$d'.
- sync-plugins.sh: codex loop counted updates even when jq/mv failed, no
validation before mv, no .tmp cleanup. Now validates + cleans up + counts
only on success. Corrected misleading 'lockstep' comment (version-only).
- generate-marketplace.sh: heredoc interpolated raw values into JSON (latent
injection if a description gained a quote/backslash/newline). Build each
entry with jq -nc --arg.
- fix-frontmatter.mjs: closing --- swallowed when description was last field;
1-space indent emitted invalid >- content; merged unrelated lists;
falsely re-folded already-valid descriptions (non-idempotent, churned 25
files). Added block-scalar idempotency guard, non-blank-continuation
check, delimiter stop, per-list indent scoping.
Minor/hygiene:
- Align divergent version defaults (1.0.0 vs 3.0.0) across codex/marketplace
generators.
- Add pipefail to generate-marketplace.sh + generate-codex-manifests.sh.
* fix(scripts): round-2 adversarial findings — propagate ((VAR++)) fix + harden date parse
Round-2 adversarial review (on commit 154847fb) found two remaining
Important issues in the same bug families:
- baseline-audit-all.sh: the ((VAR++)) counters (COMPLETED, CRITICAL_SKILLS,
HIGH_SKILLS, MEDIUM_SKILLS, CLEAN_SKILLS) had no || true guard, so under
set -euo pipefail the audit aborted on the first skill — the same bug class
fixed in review-skill.sh but not propagated to this caller. Also default
`days=0` so a missing grep match can't throw 'integer expression expected'.
- review-skill.sh: the BSD/GNU date fallback left verified_epoch=0 when the
last_verified value was a quoted scalar or non-YYYY-MM-DD format, silently
flagging the skill as ~20,500 days stale (the exact false-positive the fix
targeted). Now strips surrounding quotes, and if neither parser recognizes
the value, skips the staleness check instead of computing a bogus age.
Both verified: baseline-audit-all now runs all 182 skills to completion;
review-skill date path handles quoted/ISO/unrecognized values correctly.
* fix(fix-frontmatter): cover all YAML block-scalar headers; preserve blank lines on list break
Addresses CodeRabbit review on PR #85:
- Idempotency regex only matched >| with optional chomping (-/+). YAML also
allows indentation indicators (|2, >3) and header comments (| # c), plus
any-order indicator combos (|3+, >2-). Now parses the header token
(before any comment) against the full grammar, leaving all valid
block-scalar headers untouched.
- fixListIndentation dropped buffered blank lines when the list block broke
on a different-indentation line: the blanks were consumed (j advanced)
but never pushed to out. Buffer them as pendingBlanks and push on the
break path so trailing blanks survive in the output.
Verified: 15-case regex test all-correct; idempotent on clean repo (0 files);
validate-frontmatter + validate-json-schemas still green.
336 lines
10 KiB
Bash
Executable File
336 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Validate YAML frontmatter in SKILL.md files
|
|
#
|
|
# Aligned with the official Agent Skills specification:
|
|
# https://agentskills.io/specification
|
|
# https://github.com/agentskills/agentskills/tree/main/skills-ref
|
|
#
|
|
# Checks:
|
|
# - YAML parseability (uses Ruby's YAML parser to catch syntax errors)
|
|
# - Frontmatter delimiters (--- ... ---)
|
|
# - Required fields: name, description
|
|
# - name: lowercase, <= 64 chars, no leading/trailing/consecutive hyphens, [a-z0-9-] only
|
|
# - name matches skill directory name
|
|
# - description: non-empty, <= 1024 chars
|
|
# - compatibility: <= 500 chars if present
|
|
# - Only allowed top-level fields: name, description, license, allowed-tools, metadata, compatibility
|
|
# - Recommended field: license (warn)
|
|
#
|
|
# Usage:
|
|
# ./scripts/validate-frontmatter.sh # Validate all skills
|
|
# ./scripts/validate-frontmatter.sh --dir plugins/foo # Validate one skill
|
|
# ./scripts/validate-frontmatter.sh --quiet # Only print errors
|
|
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
GREEN='\033[0;32m'
|
|
RED='\033[0;31m'
|
|
YELLOW='\033[1;33m'
|
|
NC='\033[0m'
|
|
|
|
ALLOWED_FIELDS="name description license allowed-tools metadata compatibility"
|
|
ALLOWED_FIELDS_PATTERN="^(name|description|license|allowed-tools|metadata|compatibility)$"
|
|
MAX_NAME_LENGTH=64
|
|
MAX_DESCRIPTION_LENGTH=1024
|
|
MAX_COMPATIBILITY_LENGTH=500
|
|
|
|
CRITICAL_COUNT=0
|
|
WARNING_COUNT=0
|
|
TOTAL=0
|
|
PASSED=0
|
|
QUIET=false
|
|
|
|
validate_skill() {
|
|
local skill_file="$1"
|
|
local skill_dir
|
|
skill_dir="$(dirname "$skill_file")"
|
|
local dir_name
|
|
dir_name="$(basename "$skill_dir")"
|
|
|
|
TOTAL=$((TOTAL + 1))
|
|
local has_error=false
|
|
local has_warning=false
|
|
|
|
local first_line
|
|
first_line=$(sed -n '1p' "$skill_file")
|
|
|
|
if [ "$first_line" != "---" ]; then
|
|
if [ "$QUIET" = false ]; then
|
|
echo -e " ${RED}FAIL${NC} $dir_name: missing opening --- delimiter"
|
|
fi
|
|
CRITICAL_COUNT=$((CRITICAL_COUNT + 1))
|
|
return 1
|
|
fi
|
|
|
|
local frontmatter
|
|
frontmatter=$(awk '
|
|
/^---$/ { count++; next }
|
|
count == 1 { print }
|
|
count >= 2 { exit }
|
|
' "$skill_file")
|
|
|
|
if [ -z "$frontmatter" ]; then
|
|
if [ "$QUIET" = false ]; then
|
|
echo -e " ${RED}FAIL${NC} $dir_name: empty or missing frontmatter"
|
|
fi
|
|
CRITICAL_COUNT=$((CRITICAL_COUNT + 1))
|
|
return 1
|
|
fi
|
|
|
|
local errors=""
|
|
local warnings=""
|
|
|
|
# --- YAML parseability check (catches malformed YAML that grep/awk would miss) ---
|
|
local yaml_error=""
|
|
yaml_error=$(ruby -r yaml -r date -e "
|
|
content = ARGF.read
|
|
fm_match = content.match(/^---\n(.*?)\n---/m)
|
|
if fm_match
|
|
YAML.safe_load(fm_match[1], permitted_classes: [Date, Time])
|
|
else
|
|
exit 1
|
|
end
|
|
" < "$skill_file" 2>&1) || {
|
|
errors="${errors} YAML frontmatter failed to parse: ${yaml_error}\n"
|
|
errors="${errors} At runtime this skill loads with empty metadata (all frontmatter fields silently dropped).\n"
|
|
}
|
|
|
|
# --- Required fields ---
|
|
if ! echo "$frontmatter" | grep -q "^name:"; then
|
|
errors="${errors} missing required field 'name'\n"
|
|
fi
|
|
|
|
if ! echo "$frontmatter" | grep -q "^description:"; then
|
|
errors="${errors} missing required field 'description'\n"
|
|
fi
|
|
|
|
# --- Extract name value ---
|
|
local yaml_name
|
|
yaml_name=$(echo "$frontmatter" | grep "^name:" | sed -n '1p' | sed 's/^name:[[:space:]]*//' | sed 's/^"//' | sed 's/"$//')
|
|
|
|
# --- Name format checks (spec: lowercase, <= 64, no leading/trailing hyphen, no --, [a-z0-9-] only) ---
|
|
if [ -n "$yaml_name" ]; then
|
|
local name_lower
|
|
name_lower=$(echo "$yaml_name" | tr '[:upper:]' '[:lower:]')
|
|
if [ "$yaml_name" != "$name_lower" ]; then
|
|
errors="${errors} name '$yaml_name' must be lowercase\n"
|
|
fi
|
|
|
|
local name_len=${#yaml_name}
|
|
if [ "$name_len" -gt "$MAX_NAME_LENGTH" ]; then
|
|
errors="${errors} name exceeds ${MAX_NAME_LENGTH} chars (${name_len})\n"
|
|
fi
|
|
|
|
if [[ "$yaml_name" == -* ]]; then
|
|
errors="${errors} name cannot start with a hyphen\n"
|
|
fi
|
|
|
|
if [[ "$yaml_name" == *- ]]; then
|
|
errors="${errors} name cannot end with a hyphen\n"
|
|
fi
|
|
|
|
if [[ "$yaml_name" == *--* ]]; then
|
|
errors="${errors} name cannot contain consecutive hyphens\n"
|
|
fi
|
|
|
|
if ! echo "$yaml_name" | grep -qE '^[a-z0-9-]+$'; then
|
|
errors="${errors} name '$yaml_name' contains invalid characters (only a-z, 0-9, - allowed)\n"
|
|
fi
|
|
|
|
if [ "$yaml_name" != "$dir_name" ]; then
|
|
errors="${errors} name '$yaml_name' does not match directory '$dir_name'\n"
|
|
fi
|
|
fi
|
|
|
|
# --- Description length check (spec: <= 1024 chars) ---
|
|
local desc_text
|
|
desc_text=$(echo "$frontmatter" | awk '
|
|
/^description:/ { flag=1; sub(/^description:[[:space:]]*/, ""); print; next }
|
|
flag && /^[a-z-]+:/ { flag=0 }
|
|
flag && /^ / { sub(/^ /, ""); print }
|
|
')
|
|
|
|
if [ -n "$desc_text" ]; then
|
|
local desc_len
|
|
desc_len=$(echo "$desc_text" | tr -d '\n' | wc -c | tr -d ' ')
|
|
if [ "$desc_len" -gt "$MAX_DESCRIPTION_LENGTH" ]; then
|
|
errors="${errors} description exceeds ${MAX_DESCRIPTION_LENGTH} chars (${desc_len})\n"
|
|
fi
|
|
else
|
|
errors="${errors} missing required field 'description' or description is empty\n"
|
|
fi
|
|
|
|
# --- Compatibility length check (spec: <= 500 chars) ---
|
|
local compat_text
|
|
compat_text=$(echo "$frontmatter" | awk '
|
|
/^compatibility:/ { flag=1; sub(/^compatibility:[[:space:]]*/, ""); print; next }
|
|
flag && /^[a-z-]+:/ { flag=0 }
|
|
flag && /^ / { sub(/^ /, ""); print }
|
|
')
|
|
|
|
if [ -n "$compat_text" ]; then
|
|
local compat_len
|
|
compat_len=$(echo "$compat_text" | tr -d '\n' | wc -c | tr -d ' ')
|
|
if [ "$compat_len" -gt "$MAX_COMPATIBILITY_LENGTH" ]; then
|
|
errors="${errors} compatibility exceeds ${MAX_COMPATIBILITY_LENGTH} chars (${compat_len})\n"
|
|
fi
|
|
fi
|
|
|
|
# --- Recommended fields ---
|
|
if ! echo "$frontmatter" | grep -q "^license:"; then
|
|
warnings="${warnings} missing 'license' field (recommended)\n"
|
|
fi
|
|
|
|
# --- Unknown top-level fields (spec: error, not warning) ---
|
|
local invalid_fields
|
|
invalid_fields=$(echo "$frontmatter" | awk -v pattern="$ALLOWED_FIELDS_PATTERN" '
|
|
/^[a-z][a-z0-9-]*:/ && !/^[[:space:]]/ {
|
|
field = $0
|
|
sub(/:.*$/, "", field)
|
|
if (field !~ pattern) print field
|
|
}
|
|
')
|
|
|
|
if [ -n "$invalid_fields" ]; then
|
|
local deduped
|
|
deduped=$(echo "$invalid_fields" | sort -u)
|
|
while IFS= read -r field; do
|
|
[ -z "$field" ] && continue
|
|
errors="${errors} unknown field '$field' (allowed: $ALLOWED_FIELDS)\n"
|
|
done <<< "$deduped"
|
|
fi
|
|
|
|
# --- Output (FAIL is always printed, even in --quiet mode) ---
|
|
if [ -n "$errors" ]; then
|
|
has_error=true
|
|
CRITICAL_COUNT=$((CRITICAL_COUNT + 1))
|
|
echo -e " ${RED}FAIL${NC} $dir_name"
|
|
printf '%b' "$errors"
|
|
fi
|
|
|
|
if [ -n "$warnings" ]; then
|
|
has_warning=true
|
|
WARNING_COUNT=$((WARNING_COUNT + 1))
|
|
if [ "$QUIET" = false ]; then
|
|
echo -e " ${YELLOW}WARN${NC} $dir_name"
|
|
printf '%b' "$warnings"
|
|
fi
|
|
fi
|
|
|
|
if [ "$has_error" = false ]; then
|
|
PASSED=$((PASSED + 1))
|
|
if [ "$QUIET" = false ] && [ "$has_warning" = false ]; then
|
|
echo -e " ${GREEN} OK ${NC} $dir_name"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
usage() {
|
|
echo "Usage: $0 [OPTIONS]"
|
|
echo ""
|
|
echo "Options:"
|
|
echo " --dir <path> Validate a single skill directory"
|
|
echo " --quiet Only print errors and warnings"
|
|
echo " --help Show this help"
|
|
echo ""
|
|
echo "Examples:"
|
|
echo " $0 # Validate all skills"
|
|
echo " $0 --dir plugins/cloudflare-d1 # Validate one skill"
|
|
echo " $0 --quiet # Silent mode"
|
|
}
|
|
|
|
TARGET_DIR=""
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--dir)
|
|
TARGET_DIR="$2"
|
|
shift 2
|
|
;;
|
|
--quiet)
|
|
QUIET=true
|
|
shift
|
|
;;
|
|
--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "Unknown option: $1"
|
|
usage
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [ "$QUIET" = false ]; then
|
|
echo ""
|
|
echo "═══════════════════════════════════════"
|
|
echo " SKILL.md Frontmatter Validation"
|
|
echo " Spec: https://agentskills.io/specification"
|
|
echo "═══════════════════════════════════════"
|
|
echo ""
|
|
fi
|
|
|
|
if [ -n "$TARGET_DIR" ]; then
|
|
skill_files=""
|
|
|
|
# Priority 1: --dir points directly to a skill directory (contains SKILL.md)
|
|
if [ -f "$TARGET_DIR/SKILL.md" ]; then
|
|
skill_files="$TARGET_DIR/SKILL.md"
|
|
# Priority 2: --dir is a skills container (basename is "skills")
|
|
elif [ "$(basename "$TARGET_DIR")" = "skills" ]; then
|
|
skill_files=$(find "$TARGET_DIR" -mindepth 2 -maxdepth 2 -name 'SKILL.md' 2>/dev/null | sort || true)
|
|
# Priority 3: --dir is a plugin root (look for skills/ subdirectory)
|
|
else
|
|
skill_files=$(find "$TARGET_DIR/skills" -name 'SKILL.md' 2>/dev/null | sort || true)
|
|
fi
|
|
|
|
if [ -z "$skill_files" ]; then
|
|
echo -e "${RED}Error: No SKILL.md found in $TARGET_DIR${NC}"
|
|
exit 1
|
|
fi
|
|
|
|
while IFS= read -r file; do
|
|
[ -z "$file" ] && continue
|
|
validate_skill "$file" || true
|
|
done <<< "$skill_files"
|
|
else
|
|
skill_files=$(find "$REPO_ROOT/plugins" -name 'SKILL.md' 2>/dev/null | sort || true)
|
|
|
|
if [ -z "$skill_files" ]; then
|
|
echo -e "${RED}Error: No SKILL.md files found${NC}"
|
|
exit 1
|
|
fi
|
|
|
|
while IFS= read -r file; do
|
|
[ -z "$file" ] && continue
|
|
validate_skill "$file" || true
|
|
done <<< "$skill_files"
|
|
fi
|
|
|
|
if [ "$QUIET" = false ]; then
|
|
echo ""
|
|
echo "═══════════════════════════════════════"
|
|
echo "SUMMARY"
|
|
echo "═══════════════════════════════════════"
|
|
echo -e " Total: $TOTAL"
|
|
echo -e " Passed: ${GREEN}$PASSED${NC}"
|
|
echo -e " Failed: ${RED}$CRITICAL_COUNT${NC}"
|
|
echo -e " Warnings: ${YELLOW}$WARNING_COUNT${NC}"
|
|
echo ""
|
|
fi
|
|
|
|
if [ "$CRITICAL_COUNT" -gt 0 ]; then
|
|
echo -e "${RED}Validation failed with $CRITICAL_COUNT critical issue(s)${NC}"
|
|
exit 1
|
|
else
|
|
if [ "$QUIET" = false ]; then
|
|
echo -e "${GREEN}All frontmatter valid${NC}"
|
|
fi
|
|
exit 0
|
|
fi
|