validate-skill.sh 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. TRANSLATED CONTENT:
  2. #!/usr/bin/env bash
  3. set -euo pipefail
  4. usage() {
  5. cat <<'EOF'
  6. Usage:
  7. validate-skill.sh <skill-dir> [--strict]
  8. What it does:
  9. - Validates SKILL.md YAML frontmatter (name/description)
  10. - Performs lightweight structural checks
  11. - In --strict mode, enforces the recommended section layout
  12. Examples:
  13. ./skills/claude-skills/scripts/validate-skill.sh skills/postgresql
  14. ./skills/claude-skills/scripts/validate-skill.sh skills/my-skill --strict
  15. EOF
  16. }
  17. die() {
  18. echo "Error: $*" >&2
  19. exit 1
  20. }
  21. warn() {
  22. echo "Warning: $*" >&2
  23. }
  24. strict=0
  25. skill_dir=""
  26. while [[ $# -gt 0 ]]; do
  27. case "$1" in
  28. -h|--help)
  29. usage
  30. exit 0
  31. ;;
  32. --strict)
  33. strict=1
  34. shift
  35. ;;
  36. --)
  37. shift
  38. break
  39. ;;
  40. -*)
  41. die "Unknown argument: $1 (use --help)"
  42. ;;
  43. *)
  44. if [[ -z "$skill_dir" ]]; then
  45. skill_dir="$1"
  46. shift
  47. else
  48. die "Extra argument: $1 (only one <skill-dir> is allowed)"
  49. fi
  50. ;;
  51. esac
  52. done
  53. [[ -n "$skill_dir" ]] || { usage; exit 1; }
  54. [[ -d "$skill_dir" ]] || die "Not a directory: $skill_dir"
  55. skill_md="$skill_dir/SKILL.md"
  56. [[ -f "$skill_md" ]] || die "Missing SKILL.md: $skill_md"
  57. base_name="$(basename -- "${skill_dir%/}")"
  58. # -------------------- Parse YAML frontmatter --------------------
  59. frontmatter=""
  60. if frontmatter="$(
  61. awk '
  62. BEGIN { in_fm=0; closed=0 }
  63. NR==1 {
  64. if ($0 != "---") exit 2
  65. in_fm=1
  66. next
  67. }
  68. in_fm==1 {
  69. if ($0 == "---") { closed=1; exit 0 }
  70. print
  71. next
  72. }
  73. END {
  74. if (closed == 0) exit 3
  75. }
  76. ' "$skill_md"
  77. )"; then
  78. :
  79. else
  80. rc=$?
  81. case "$rc" in
  82. 2) die "SKILL.md must start with YAML frontmatter (--- as the first line)" ;;
  83. 3) die "YAML frontmatter is not closed (missing ---)" ;;
  84. *) die "Failed to parse YAML frontmatter (awk exit=$rc)" ;;
  85. esac
  86. fi
  87. name="$(
  88. printf "%s\n" "$frontmatter" | awk -F: '
  89. tolower($1) ~ /^name$/ {
  90. sub(/^[^:]*:[[:space:]]*/, "", $0)
  91. gsub(/[[:space:]]+$/, "", $0)
  92. print
  93. exit
  94. }
  95. '
  96. )"
  97. description="$(
  98. printf "%s\n" "$frontmatter" | awk -F: '
  99. tolower($1) ~ /^description$/ {
  100. sub(/^[^:]*:[[:space:]]*/, "", $0)
  101. gsub(/[[:space:]]+$/, "", $0)
  102. print
  103. exit
  104. }
  105. '
  106. )"
  107. [[ -n "$name" ]] || die "Missing frontmatter field: name"
  108. [[ -n "$description" ]] || die "Missing frontmatter field: description"
  109. if [[ ! "$name" =~ ^[a-z][a-z0-9-]*$ ]]; then
  110. die "Invalid name: '$name' (expected ^[a-z][a-z0-9-]*$)"
  111. fi
  112. if [[ "$strict" -eq 1 && "$name" != "$base_name" ]]; then
  113. die "Strict mode: frontmatter name ('$name') must match directory name ('$base_name')"
  114. fi
  115. # -------------------- Strip fenced code blocks for section checks --------------------
  116. filtered_md="$(mktemp)"
  117. trap 'rm -f "$filtered_md"' EXIT
  118. awk '
  119. BEGIN { in_fence=0 }
  120. /^[[:space:]]*```/ { in_fence = !in_fence; next }
  121. in_fence==0 { print }
  122. ' "$skill_md" > "$filtered_md"
  123. # -------------------- Structural checks --------------------
  124. required_h2=(
  125. "When to Use This Skill"
  126. "Not For / Boundaries"
  127. "Quick Reference"
  128. "Examples"
  129. "References"
  130. "Maintenance"
  131. )
  132. for title in "${required_h2[@]}"; do
  133. if ! grep -Eq "^##[[:space:]]+${title}([[:space:]]*)$" "$filtered_md"; then
  134. if [[ "$strict" -eq 1 ]]; then
  135. die "Strict mode: missing required section heading: '## ${title}'"
  136. fi
  137. warn "Missing recommended section heading: '## ${title}'"
  138. fi
  139. done
  140. # references/index.md presence (only enforced in strict mode when references/ exists)
  141. if [[ -d "$skill_dir/references" && "$strict" -eq 1 && ! -f "$skill_dir/references/index.md" ]]; then
  142. die "Strict mode: references/ exists but references/index.md is missing"
  143. fi
  144. # -------------------- Heuristics: Quick Reference size --------------------
  145. quick_start="$(awk 'match($0, /^##[[:space:]]+Quick Reference([[:space:]]*)$/){print NR; exit}' "$filtered_md" || true)"
  146. if [[ -n "$quick_start" ]]; then
  147. quick_end="$(awk -v s="$quick_start" 'NR>s && match($0, /^##[[:space:]]+/){print NR; exit}' "$filtered_md" || true)"
  148. total_lines="$(wc -l < "$filtered_md" | tr -d ' ')"
  149. if [[ -z "$quick_end" ]]; then
  150. quick_end="$((total_lines + 1))"
  151. fi
  152. quick_len="$((quick_end - quick_start - 1))"
  153. if [[ "$quick_len" -gt 250 ]]; then
  154. if [[ "$strict" -eq 1 ]]; then
  155. die "Strict mode: Quick Reference section is too long (${quick_len} lines). Move long-form text into references/."
  156. fi
  157. warn "Quick Reference section is large (${quick_len} lines). Consider moving long-form text into references/."
  158. fi
  159. fi
  160. # -------------------- Heuristics: Examples count --------------------
  161. examples_start="$(awk 'match($0, /^##[[:space:]]+Examples([[:space:]]*)$/){print NR; exit}' "$filtered_md" || true)"
  162. if [[ -n "$examples_start" ]]; then
  163. examples_end="$(awk -v s="$examples_start" 'NR>s && match($0, /^##[[:space:]]+/){print NR; exit}' "$filtered_md" || true)"
  164. total_lines="$(wc -l < "$filtered_md" | tr -d ' ')"
  165. if [[ -z "$examples_end" ]]; then
  166. examples_end="$((total_lines + 1))"
  167. fi
  168. example_count="$(
  169. awk -v s="$examples_start" -v e="$examples_end" '
  170. NR>s && NR<e && match($0, /^###[[:space:]]+Example([[:space:]]|$)/) { c++ }
  171. END { print c+0 }
  172. ' "$filtered_md"
  173. )"
  174. if [[ "$example_count" -lt 3 ]]; then
  175. if [[ "$strict" -eq 1 ]]; then
  176. die "Strict mode: expected >= 3 examples (found ${example_count})."
  177. fi
  178. warn "Recommended: >= 3 examples (found ${example_count})."
  179. fi
  180. fi
  181. echo "OK: $skill_dir"