#!/usr/bin/env bash set -euo pipefail # ╔══════════════════════════════════════════════════════════════════╗ # ║ Agent Skill Scanner v4 ║ # ║ Validates skill packages against the agentskills.io spec ║ # ║ and best practices. Runs locally and in CI (GitHub Actions). ║ # ║ ║ # ║ Spec: https://agentskills.io/specification ║ # ║ Guide: https://agentskills.io/skill-creation ║ # ║ ║ # ║ Token Estimation: ║ # ║ - Uses character/4 approximation (industry standard) ║ # ║ - Accurate within ~10% for English text ║ # ║ - Based on OpenAI tiktoken cl100k_base encoding ║ # ║ ║ # ║ Quality Score: 0-100 across 5 dimensions ║ # ║ - Description Quality (30) · Spec Compliance (20) ║ # ║ - Instruction Clarity (25) · Progressive Disclosure (15) ║ # ║ - Security (10) ║ # ╚══════════════════════════════════════════════════════════════════╝ # # Usage: # ./scripts/validate.sh # full scan # ./scripts/validate.sh --help # show usage # # Environment: # CI=true — emits GitHub Actions annotations (auto-detected) # NO_COLOR=1 — disable colored output SCAN_START=$SECONDS ERRORS=0 WARNINGS=0 INFO_COUNT=0 CHECKS_RUN=0 TOTAL_CHECKS=15 CURRENT_SECTION="" CI="${CI:-false}" NO_COLOR="${NO_COLOR:-0}" OUTPUT_MODE="terminal" SKILL_FILE="SKILL.md" SKILL_DIR="$(basename "$(pwd)")" FINDINGS_FILE=$(mktemp) trap 'rm -f "$FINDINGS_FILE"' EXIT # ── Spec limits (agentskills.io/specification) ───────────────────── NAME_MAX_LEN=64 DESC_MAX_LEN=1024 DESC_MIN_USEFUL=30 BODY_MAX_LINES=500 TOKEN_BUDGET=5000 COMPAT_MAX_LEN=500 # ── Token budget zones ──────────────────────────────────────────── TOKEN_SAFE_ZONE=3500 TOKEN_WARN_ZONE=5000 TOKEN_DANGER_ZONE=8000 REF_TOKEN_SAFE=2000 REF_TOKEN_WARN=3500 REF_TOKEN_DANGER=4500 TOTAL_TOKEN_SAFE=50000 TOTAL_TOKEN_WARN=100000 TOTAL_TOKEN_DANGER=200000 # ── Score trackers (populated during checks, consumed by scoring) ── _S_HAS_NAME=false _S_NAME_OK=false _S_HAS_DESC=false _S_DESC_LEN=0 _S_DESC_WHAT=false _S_DESC_WHEN=false _S_DESC_FIRST_PERSON=false _S_DESC_GENERIC=false _S_DESC_NEGATIVES=false _S_CODE_BLOCKS=0 _S_HEADINGS=0 _S_REF_LINKS=0 _S_BODY_LINES=0 _S_BODY_TOKENS=0 _S_HAS_REFS_DIR=false _S_REFS_LINKED=0 _S_REFS_TOTAL=0 _S_FENCES_OK=true _S_SECRETS=0 _S_HARDCODED=0 _S_DANGEROUS=0 QUALITY_SCORE=0 QUALITY_GRADE="F" QUALITY_DESC=0 QUALITY_CLARITY=0 QUALITY_SPEC=0 QUALITY_PROGRESSIVE=0 QUALITY_SECURITY=0 # ── Colors & formatting ─────────────────────────────────────────── if [ "$NO_COLOR" = "1" ]; then _red() { printf '%s' "$*"; } _green() { printf '%s' "$*"; } _yellow() { printf '%s' "$*"; } _blue() { printf '%s' "$*"; } _cyan() { printf '%s' "$*"; } _magenta() { printf '%s' "$*"; } _bold() { printf '%s' "$*"; } _dim() { printf '%s' "$*"; } _bold_green() { printf '%s' "$*"; } _bold_red() { printf '%s' "$*"; } _bold_yellow() { printf '%s' "$*"; } _bold_cyan() { printf '%s' "$*"; } else _red() { printf "\033[0;31m%s\033[0m" "$*"; } _green() { printf "\033[0;32m%s\033[0m" "$*"; } _yellow() { printf "\033[0;33m%s\033[0m" "$*"; } _blue() { printf "\033[0;34m%s\033[0m" "$*"; } _cyan() { printf "\033[0;36m%s\033[0m" "$*"; } _magenta() { printf "\033[0;35m%s\033[0m" "$*"; } _bold() { printf "\033[1m%s\033[0m" "$*"; } _dim() { printf "\033[2m%s\033[0m" "$*"; } _bold_green() { printf "\033[1;32m%s\033[0m" "$*"; } _bold_red() { printf "\033[1;31m%s\033[0m" "$*"; } _bold_yellow() { printf "\033[1;33m%s\033[0m" "$*"; } _bold_cyan() { printf "\033[1;36m%s\033[0m" "$*"; } fi # ── Logging ──────────────────────────────────────────────────────── _error() { ERRORS=$((ERRORS + 1)) [ "$OUTPUT_MODE" = "terminal" ] && echo " $(_red "✗ ERROR") $*" || true echo "ERROR|${CURRENT_SECTION}|$*" >> "$FINDINGS_FILE" } _warn() { WARNINGS=$((WARNINGS + 1)) [ "$OUTPUT_MODE" = "terminal" ] && echo " $(_yellow "! WARN ") $*" || true echo "WARN|${CURRENT_SECTION}|$*" >> "$FINDINGS_FILE" } _pass() { [ "$OUTPUT_MODE" = "terminal" ] && echo " $(_green "✓ PASS ") $*" || true } _info() { INFO_COUNT=$((INFO_COUNT + 1)) [ "$OUTPUT_MODE" = "terminal" ] && echo " $(_blue "ℹ INFO ") $*" || true echo "INFO|${CURRENT_SECTION}|$*" >> "$FINDINGS_FILE" } _detail() { [ "$OUTPUT_MODE" = "terminal" ] && echo " $(_dim "$*")" || true } ci_annotate() { local level="$1"; shift if [ "$CI" = "true" ]; then echo "::${level} $*" fi } section() { CHECKS_RUN=$((CHECKS_RUN + 1)) CURRENT_SECTION="$1" if [ "$OUTPUT_MODE" = "terminal" ]; then echo "" echo " $(_bold_cyan "[$CHECKS_RUN/$TOTAL_CHECKS]") $(_bold "$1")" echo " $(_dim "$(printf '%.0s─' $(seq 1 60))")" fi } file_lines() { wc -l < "$1" | tr -d ' '; } file_chars() { wc -c < "$1" | tr -d ' '; } file_words() { wc -w < "$1" | tr -d ' '; } estimate_tokens() { local chars chars=$(file_chars "$1") echo $(( chars / 4 )) } estimate_tokens_detailed() { local file="$1" local chars words lines char_tokens word_tokens chars=$(file_chars "$file") words=$(file_words "$file") lines=$(file_lines "$file") char_tokens=$(( chars / 4 )) word_tokens=$(( (words * 13) / 10 )) echo "$char_tokens|$chars|$words|$lines|$word_tokens" } token_zone_color() { local tokens="$1" safe="$2" warn="$3" if [ "$tokens" -le "$safe" ]; then echo "green" elif [ "$tokens" -le "$warn" ]; then echo "yellow" else echo "red" fi } format_tokens_colored() { local tokens="$1" safe="$2" warn="$3" label="${4:-tokens}" local zone zone=$(token_zone_color "$tokens" "$safe" "$warn") case "$zone" in green) printf "%s" "$(_green "~$tokens $label")" ;; yellow) printf "%s" "$(_yellow "~$tokens $label")" ;; red) printf "%s" "$(_red "~$tokens $label")" ;; esac } token_zone_indicator() { local tokens="$1" safe="$2" warn="$3" local zone pct zone=$(token_zone_color "$tokens" "$safe" "$warn") pct=$(( (tokens * 100) / warn )) case "$zone" in green) if [ "$NO_COLOR" = "1" ]; then echo "[SAFE $pct%]" else echo "$(_green "[SAFE $pct%]")"; fi ;; yellow) if [ "$NO_COLOR" = "1" ]; then echo "[WARN $pct%]" else echo "$(_yellow "[WARN $pct%]")"; fi ;; red) if [ "$NO_COLOR" = "1" ]; then echo "[HIGH $pct%]" else echo "$(_red "[HIGH $pct%]")"; fi ;; esac } # ── Visual helpers ───────────────────────────────────────────────── bar_gauge() { local value="$1" max="$2" width="${3:-20}" local pct filled empty bar="" if [ "$max" -le 0 ]; then pct=0 else pct=$(( (value * 100) / max )); fi [ "$pct" -gt 100 ] && pct=100 filled=$(( (pct * width) / 100 )) empty=$(( width - filled )) local i for ((i=0; i/dev/null | tr -d ' ' || true) if [ -z "$result" ] || ! [[ "$result" =~ ^[0-9]+$ ]]; then echo "0" else echo "$result" fi } # ════════════════════════════════════════════════════════════════════ # CHECKS # ════════════════════════════════════════════════════════════════════ # ── [1] Skill Structure ─────────────────────────────────────────── check_structure() { section "Skill Structure" if [ ! -f "$SKILL_FILE" ]; then _error "SKILL.md not found — required by agentskills.io spec" ci_annotate "error" "file=SKILL.md::SKILL.md not found" return fi _pass "SKILL.md ($(file_lines "$SKILL_FILE") lines, $(file_chars "$SKILL_FILE") bytes)" if [ ! -f "README.md" ]; then _warn "README.md missing — recommended for discoverability and GitHub rendering" ci_annotate "warning" "file=README.md::README.md missing (recommended)" else _pass "README.md ($(file_lines README.md) lines)" fi local dirs=("references" "scripts" "assets" "agents") for dir in "${dirs[@]}"; do if [ -d "$dir" ]; then local count count=$(find "$dir" -type f | wc -l | tr -d ' ') _pass "$dir/ ($count files)" if [ "$dir" = "references" ]; then _S_HAS_REFS_DIR=true fi fi done if [ ! -d "references" ]; then _info "No references/ directory" _detail "Add reference docs for progressive disclosure of complex content" fi if [ -f "LICENSE" ] || [ -f "LICENSE.md" ] || [ -f "LICENSE.txt" ]; then _pass "License file found" fi } # ── [2] Frontmatter ─────────────────────────────────────────────── check_frontmatter() { [ ! -f "$SKILL_FILE" ] && return section "Frontmatter & Description Quality" local first_line first_line=$(head -1 "$SKILL_FILE") if [ "$first_line" != "---" ]; then _error "Line 1: Expected '---' delimiter, found: '$first_line'" ci_annotate "error" "file=$SKILL_FILE,line=1::Missing YAML frontmatter delimiter" return fi local frontmatter frontmatter=$(sed -n '2,/^---$/p' "$SKILL_FILE" | sed '$d') # ── name ── local name name=$(echo "$frontmatter" | grep '^name:' | head -1 | sed 's/^name:[[:space:]]*//') if [ -z "$name" ]; then _error "Required field 'name' missing" ci_annotate "error" "file=$SKILL_FILE::Missing required 'name' field" else _S_HAS_NAME=true local name_len=${#name} local name_ok=true if [ "$name_len" -gt "$NAME_MAX_LEN" ]; then _error "name '$name' is $name_len chars (max $NAME_MAX_LEN)" ci_annotate "error" "file=$SKILL_FILE::name exceeds $NAME_MAX_LEN chars" name_ok=false fi if echo "$name" | grep -qE '[A-Z]'; then _error "name '$name' has uppercase — spec requires lowercase only" ci_annotate "error" "file=$SKILL_FILE::name must be lowercase" name_ok=false fi if echo "$name" | grep -qE '[^a-z0-9-]'; then _error "name '$name' has invalid chars — only a-z, 0-9, hyphens allowed" ci_annotate "error" "file=$SKILL_FILE::name contains invalid characters" name_ok=false fi if echo "$name" | grep -qE '^-|-$'; then _error "name '$name' starts or ends with hyphen" ci_annotate "error" "file=$SKILL_FILE::name starts/ends with hyphen" name_ok=false fi if echo "$name" | grep -qF -- '--'; then _error "name '$name' has consecutive hyphens (--)" ci_annotate "error" "file=$SKILL_FILE::name has consecutive hyphens" name_ok=false fi if [ "$name_ok" = true ]; then _pass "name: '$name' ($name_len chars)" _S_NAME_OK=true fi if [ "$name" != "$SKILL_DIR" ]; then _warn "name '$name' ≠ directory '$SKILL_DIR'" _detail "Spec: name should match parent directory name" ci_annotate "warning" "file=$SKILL_FILE::name doesn't match directory name" else _pass "name matches directory name" fi fi # ── description ── local desc_line desc="" desc_line=$(echo "$frontmatter" | grep -n '^description:' | head -1 | cut -d: -f1) if [ -z "$desc_line" ]; then _error "Required field 'description' missing" ci_annotate "error" "file=$SKILL_FILE::Missing required 'description' field" else _S_HAS_DESC=true local inline_desc inline_desc=$(echo "$frontmatter" | sed -n "${desc_line}p" | sed 's/^description:[[:space:]]*//') if [ -n "$inline_desc" ] && ! echo "$inline_desc" | grep -qE '^\s*[>|]\s*$'; then desc="$inline_desc" else desc=$(echo "$frontmatter" | sed -n "$((desc_line+1)),\$p" | sed '/^[a-zA-Z_-]*:/,$d' | tr '\n' ' ' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') fi local desc_len=${#desc} _S_DESC_LEN=$desc_len if [ "$desc_len" -eq 0 ]; then _error "description is empty" ci_annotate "error" "file=$SKILL_FILE::description is empty" else if [ "$desc_len" -gt "$DESC_MAX_LEN" ]; then _warn "description is $desc_len chars (spec max: $DESC_MAX_LEN)" ci_annotate "warning" "file=$SKILL_FILE::description exceeds $DESC_MAX_LEN chars" else _pass "description length: $desc_len chars (limit: $DESC_MAX_LEN)" fi if [ "$desc_len" -lt "$DESC_MIN_USEFUL" ]; then _warn "description is very short ($desc_len chars) — likely won't trigger well" _detail "Good: 'Extract text and tables from PDF files, fill forms, merge" _detail " documents. Use when working with PDF documents.'" _detail "Bad: 'Helps with PDFs.'" ci_annotate "warning" "file=$SKILL_FILE::description too short for reliable triggering" fi # WHAT and WHEN analysis local has_what=false has_when=false if echo "$desc" | grep -qiE 'build|create|generate|extract|analyze|process|manage|handle|configure|review|refactor|optimize|test|debug|deploy|format|validate|convert|transform|monitor|implement'; then has_what=true _S_DESC_WHAT=true fi if echo "$desc" | grep -qiE 'use when|when working|when the user|when handling|if the user|for tasks|for working|designed for|use this|use for'; then has_when=true _S_DESC_WHEN=true fi if [ "$has_what" = true ] && [ "$has_when" = true ]; then _pass "description covers WHAT and WHEN to use" elif [ "$has_what" = false ] && [ "$has_when" = false ]; then _warn "description may lack WHAT the skill does and WHEN to use it" _detail "Spec: 'Describes what the skill does and when to use it'" elif [ "$has_when" = false ]; then _info "description explains WHAT but could clarify WHEN to trigger" _detail "Adding 'Use when...' helps agents decide when to activate" fi # ── [NEW] First-person voice ── if echo "$desc" | grep -qiE '\bI can\b|\bI will\b|\bI help\b|\bI am\b|\bI provide\b|\bmy skill\b'; then _warn "description uses first-person voice ('I can', 'I will', etc.)" _detail "Descriptions are injected into the agent system prompt — first-person" _detail "mixes viewpoints and confuses agent reasoning. Use third person instead" ci_annotate "warning" "file=$SKILL_FILE::description uses first-person voice" _S_DESC_FIRST_PERSON=true else _pass "description uses correct voice (not first-person)" fi # ── [NEW] Generic verb detection ── local generic_matches generic_matches=$(echo "$desc" | grep -oiE '\b(manage|handle|deal with|work with|help with|assist with|take care of)\b' 2>/dev/null | head -3 || true) if [ -n "$generic_matches" ]; then local generic_list generic_list=$(echo "$generic_matches" | tr '\n' ', ' | sed 's/,$//') _info "description contains generic verbs: $generic_list" _detail "Specific verbs (extract, generate, validate) improve trigger precision" _S_DESC_GENERIC=true else _pass "description uses specific action verbs" fi # ── [NEW] Negative trigger detection ── if echo "$desc" | grep -qiE 'not for|not designed for|do not use|does not|don.t use'; then _pass "description includes boundary markers (negative triggers)" _S_DESC_NEGATIVES=true else _info "no negative triggers ('NOT for...', 'Do not use when...')" _detail "Negative triggers prevent mis-activation when many skills are loaded" fi fi fi # ── optional fields ── echo "" _detail "Optional fields:" if echo "$frontmatter" | grep -q '^license:'; then local license_val license_val=$(echo "$frontmatter" | grep '^license:' | sed 's/^license:[[:space:]]*//') _pass "license: $license_val" else _info "No license field — recommended for shared/public skills" fi if echo "$frontmatter" | grep -q '^compatibility:'; then local compat compat=$(echo "$frontmatter" | grep '^compatibility:' | sed 's/^compatibility:[[:space:]]*//') local compat_len=${#compat} if [ "$compat_len" -gt "$COMPAT_MAX_LEN" ]; then _warn "compatibility is $compat_len chars (spec max: $COMPAT_MAX_LEN)" else _pass "compatibility field present ($compat_len chars)" fi fi if echo "$frontmatter" | grep -q '^metadata:'; then _pass "metadata field present" if echo "$frontmatter" | grep -q '^\s*version:'; then _pass "metadata.version set" fi if echo "$frontmatter" | grep -q '^\s*author:'; then _pass "metadata.author set" fi fi if echo "$frontmatter" | grep -q '^allowed-tools:'; then _pass "allowed-tools field present (experimental)" fi } # ── [3] Body Content & Progressive Disclosure ───────────────────── check_body() { [ ! -f "$SKILL_FILE" ] && return section "Body Content & Progressive Disclosure" local body_start body_start=$(grep -n '^---$' "$SKILL_FILE" | sed -n '2p' | cut -d: -f1) if [ -z "$body_start" ]; then _error "No closing frontmatter delimiter (---) found" ci_annotate "error" "file=$SKILL_FILE::Missing closing frontmatter delimiter" return fi local body_lines token_est body_lines=$(tail -n +"$((body_start + 1))" "$SKILL_FILE" | wc -l | tr -d ' ') token_est=$(estimate_tokens "$SKILL_FILE") _S_BODY_LINES=$body_lines _S_BODY_TOKENS=$token_est # Line count with bar gauge if [ "$body_lines" -eq 0 ]; then _error "SKILL.md body is empty — agents need instructions" ci_annotate "error" "file=$SKILL_FILE::Empty body" elif [ "$body_lines" -gt "$BODY_MAX_LINES" ]; then _warn "Body: $body_lines lines (spec recommends <$BODY_MAX_LINES)" _detail "Move detailed content to references/ for on-demand loading" ci_annotate "warning" "file=$SKILL_FILE::Body exceeds $BODY_MAX_LINES lines" else _pass "Body: $body_lines lines (limit: $BODY_MAX_LINES)" fi local lines_gauge lines_gauge=$(bar_gauge_colored "$body_lines" "$BODY_MAX_LINES" 20 "$(( BODY_MAX_LINES * 70 / 100 ))" "$BODY_MAX_LINES") [ "$OUTPUT_MODE" = "terminal" ] && echo " $lines_gauge" || true # Token budget with bar gauge if [ "$token_est" -gt "$TOKEN_BUDGET" ]; then _warn "~$token_est tokens (spec recommends <$TOKEN_BUDGET on activation)" _detail "Progressive disclosure: SKILL.md body loads fully on activation" _detail "Keep instructions concise, move reference material to references/" ci_annotate "warning" "file=$SKILL_FILE::Estimated $token_est tokens exceeds $TOKEN_BUDGET budget" else _pass "~$token_est tokens (budget: $TOKEN_BUDGET)" fi local token_gauge token_gauge=$(bar_gauge_colored "$token_est" "$TOKEN_DANGER_ZONE" 20 "$TOKEN_SAFE_ZONE" "$TOKEN_WARN_ZONE") [ "$OUTPUT_MODE" = "terminal" ] && echo " $token_gauge" || true # Structure analysis local heading_count heading_count=$(tail -n +"$((body_start + 1))" "$SKILL_FILE" | grep -c '^#' 2>/dev/null || true) heading_count=${heading_count:-0} _S_HEADINGS=$heading_count if [ "$heading_count" -eq 0 ]; then _warn "No headings in body — add structure for readability" else _pass "$heading_count heading(s) providing structure" fi # Code examples local fence_pattern='```' local code_fence_count code_fence_count=$(grep -cF "$fence_pattern" "$SKILL_FILE" 2>/dev/null || true) code_fence_count=${code_fence_count:-0} local code_pairs=$(( code_fence_count / 2 )) _S_CODE_BLOCKS=$code_pairs local inline_code_count inline_code_count=$(grep -c '`[^`]' "$SKILL_FILE" 2>/dev/null || true) inline_code_count=${inline_code_count:-0} if [ "$code_pairs" -gt 0 ]; then _pass "$code_pairs fenced code example(s) in body" elif [ "$inline_code_count" -gt 0 ]; then _pass "$inline_code_count line(s) with inline code references" else _info "No code examples in body — consider adding for clarity" fi # Reference links (progressive disclosure pattern) local ref_link_count ref_link_count=$(tail -n +"$((body_start + 1))" "$SKILL_FILE" | grep -cE '\]\(references/' 2>/dev/null || true) ref_link_count=${ref_link_count:-0} _S_REF_LINKS=$ref_link_count if [ "$ref_link_count" -gt 0 ]; then _pass "$ref_link_count reference link(s) — using progressive disclosure" elif [ -d "references" ]; then local ref_file_count ref_file_count=$(find references -name '*.md' -type f | wc -l | tr -d ' ') if [ "$ref_file_count" -gt 0 ]; then _warn "references/ has $ref_file_count files but body has no links to them" _detail "Link references from SKILL.md body so agents can load them on demand" fi fi } # ── [4] Internal Links ──────────────────────────────────────────── check_links() { [ ! -f "$SKILL_FILE" ] && return section "Internal Links" local link_data link_data=$(grep -nF '](' "$SKILL_FILE" || true) if [ -z "$link_data" ]; then _info "No internal links in SKILL.md" return fi local checked=0 broken=0 while IFS= read -r match; do local line_num line_content line_num=$(echo "$match" | cut -d: -f1) line_content=$(echo "$match" | cut -d: -f2-) local targets targets=$(echo "$line_content" | tr ']' '\n' | grep '^(' | sed 's/^(\([^)]*\)).*/\1/' | sed '/^$/d') [ -z "$targets" ] && continue while IFS= read -r link_path; do case "$link_path" in http*|https*|"#"*|"") continue ;; esac local file_path file_path=$(echo "$link_path" | cut -d'#' -f1) [ -z "$file_path" ] && continue checked=$((checked + 1)) if [ -f "$file_path" ]; then _pass "Line $line_num: $link_path → $(file_lines "$file_path") lines" else _error "Line $line_num: $link_path → FILE NOT FOUND" ci_annotate "error" "file=$SKILL_FILE,line=$line_num::Broken link: $link_path" broken=$((broken + 1)) fi done <<< "$targets" done <<< "$link_data" echo "" _detail "Checked $checked link(s), $broken broken" } # ── [5] Reference Files ────────────────────────────────────────── check_references() { [ ! -d "references" ] && return section "Reference Files" local ref_links="" if [ -f "$SKILL_FILE" ]; then ref_links=$(grep -oE '\]\(references/[^)]+\)' "$SKILL_FILE" | sed 's/\](\(.*\))/\1/' | cut -d'#' -f1 | sort -u || true) fi local total=0 linked=0 orphaned=0 empty=0 echo "" printf " $(_dim " %-35s %-8s %-8s %s")\n" "FILE" "LINES" "STATUS" "" echo " $(_dim " $(printf '%.0s─' $(seq 1 56))")" while IFS= read -r file; do total=$((total + 1)) local lines lines=$(file_lines "$file") if [ "$lines" -eq 0 ]; then printf " $(_yellow " %-35s %-8s %-8s")\n" "$(basename "$file")" "0" "EMPTY" _warn "$file is empty (0 lines)" ci_annotate "warning" "file=$file::Empty reference file" empty=$((empty + 1)) continue fi if echo "$ref_links" | grep -qF "$file"; then linked=$((linked + 1)) printf " $(_green " %-35s %-8s %-8s")\n" "$(basename "$file")" "$lines" "LINKED" else orphaned=$((orphaned + 1)) printf " $(_yellow " %-35s %-8s %-8s")\n" "$(basename "$file")" "$lines" "ORPHAN" _warn "$file — $(_yellow "orphaned")" _detail "Not linked from SKILL.md — agents won't discover this file" ci_annotate "warning" "file=$file::Not linked from SKILL.md (orphaned)" fi done < <(find references -name '*.md' -type f | sort) _S_REFS_TOTAL=$total _S_REFS_LINKED=$linked # Missing references (linked in SKILL.md but don't exist) local missing=0 if [ -n "$ref_links" ]; then for ref in $ref_links; do if [ ! -f "$ref" ]; then _error "$ref → linked in SKILL.md but file is missing" ci_annotate "error" "file=$ref::Referenced in SKILL.md but does not exist" missing=$((missing + 1)) fi done fi echo "" echo " $(_dim " ┌──────────────────────────────────────────┐")" printf " $(_dim " │") Total: %-4s Linked: %-4s Orphaned: %-4s$(_dim "│")\n" "$total" "$linked" "$orphaned" printf " $(_dim " │") Empty: %-4s Missing: %-4s $(_dim "│")\n" "$empty" "$missing" echo " $(_dim " └──────────────────────────────────────────┘")" } # ── [6] Markdown Syntax ────────────────────────────────────────── check_markdown() { section "Markdown Syntax" local pattern file_count=0 issues=0 pattern='```' while IFS= read -r file; do file_count=$((file_count + 1)) local count count=$(grep -cF "$pattern" "$file" 2>/dev/null || true) count=${count:-0} if [ "$count" -gt 0 ] && [ $((count % 2)) -ne 0 ]; then _warn "$file — unclosed code block ($count fences)" grep -nF "$pattern" "$file" 2>/dev/null | while IFS= read -r m; do _detail "Line $(echo "$m" | cut -d: -f1): $(echo "$m" | cut -d: -f2-)" done ci_annotate "warning" "file=$file::Unclosed code block ($count fences)" issues=$((issues + 1)) fi done < <(find . -name '*.md' -not -path './.git/*' | sort) if [ "$issues" -eq 0 ]; then _pass "All $file_count markdown files have balanced code fences" else _S_FENCES_OK=false fi } # ── [7] Reference Nesting ──────────────────────────────────────── check_reference_depth() { [ ! -d "references" ] && return section "Reference Nesting" local deep_refs=0 while IFS= read -r file; do local nested nested=$(grep -cE '\]\(references/' "$file" 2>/dev/null || true) if [ "$nested" -gt 0 ]; then _warn "$file → $nested cross-reference(s) to other reference files" _detail "Spec: keep file references one level deep from SKILL.md" ci_annotate "warning" "file=$file::Cross-references between reference files" deep_refs=$((deep_refs + 1)) fi done < <(find references -name '*.md' -type f | sort) if [ "$deep_refs" -eq 0 ]; then _pass "No nested reference chains — clean one-level structure" else _detail "$deep_refs file(s) with cross-references" fi } # ── [8] Scripts Validation ─────────────────────────────────────── check_scripts() { [ ! -d "scripts" ] && return section "Scripts" local total=0 executable=0 not_executable=0 documented=0 while IFS= read -r script; do total=$((total + 1)) local basename_script basename_script=$(basename "$script") if [ -x "$script" ]; then executable=$((executable + 1)) _pass "$script (executable, $(file_lines "$script") lines)" else not_executable=$((not_executable + 1)) _warn "$script is not executable" _detail "Run: chmod +x $script" ci_annotate "warning" "file=$script::Script is not executable" fi if [ -f "$SKILL_FILE" ] && grep -qF "$basename_script" "$SKILL_FILE"; then documented=$((documented + 1)) fi done < <(find scripts -type f | sort) if [ "$total" -gt 0 ] && [ "$documented" -eq 0 ] && [ -f "$SKILL_FILE" ]; then _info "No scripts referenced in SKILL.md body" _detail "Document available scripts so agents know what tools they can run" elif [ "$documented" -gt 0 ]; then _pass "$documented of $total script(s) documented in SKILL.md" fi } # ── [9] Package Integrity ──────────────────────────────────────── check_repo_hygiene() { section "Repository Hygiene" local sensitive sensitive=$(find . -maxdepth 3 \ \( -name '.env' -o -name '*.key' -o -name '*.pem' -o -name 'credentials*' \) \ -not -path './.git/*' 2>/dev/null || true) if [ -n "$sensitive" ]; then _warn "Possible sensitive files detected" echo "$sensitive" | while IFS= read -r f; do _detail " $f" done else _pass "No sensitive files (.env, .key, .pem, credentials)" fi local artifacts artifacts=$(find . -maxdepth 3 \ \( -name 'node_modules' -o -name '__pycache__' -o -name '.DS_Store' \) \ -not -path './.git/*' 2>/dev/null || true) if [ -n "$artifacts" ]; then _warn "Development artifacts found — add to .gitignore" echo "$artifacts" | while IFS= read -r f; do _detail " $f" done else _pass "No development artifacts (node_modules, __pycache__, .DS_Store)" fi } # ── [10] Token Budget Analysis ──────────────────────────────────── check_token_budget() { section "Token Budget Analysis" if [ "$OUTPUT_MODE" = "terminal" ]; then echo ""; fi _detail "Token estimation: chars/4 (tiktoken cl100k_base approximation)" _detail "Zone thresholds based on agentskills.io and context engineering best practices" if [ "$OUTPUT_MODE" = "terminal" ]; then echo ""; fi local total_tokens=0 local total_chars=0 local total_words=0 local total_lines=0 local file_count=0 # ── SKILL.md analysis ── if [ -f "$SKILL_FILE" ]; then local skill_data skill_tokens skill_chars skill_words skill_lines skill_data=$(estimate_tokens_detailed "$SKILL_FILE") skill_tokens=$(echo "$skill_data" | cut -d'|' -f1) skill_chars=$(echo "$skill_data" | cut -d'|' -f2) skill_words=$(echo "$skill_data" | cut -d'|' -f3) skill_lines=$(echo "$skill_data" | cut -d'|' -f4) local zone_indicator zone_indicator=$(token_zone_indicator "$skill_tokens" "$TOKEN_SAFE_ZONE" "$TOKEN_WARN_ZONE") if [ "$OUTPUT_MODE" = "terminal" ]; then echo " $(_bold "SKILL.md") $zone_indicator" echo " ├─ $(format_tokens_colored "$skill_tokens" "$TOKEN_SAFE_ZONE" "$TOKEN_WARN_ZONE" "tokens")" local skill_gauge skill_gauge=$(bar_gauge_colored "$skill_tokens" "$TOKEN_DANGER_ZONE" 20 "$TOKEN_SAFE_ZONE" "$TOKEN_WARN_ZONE") echo " ├─ $skill_gauge" echo " ├─ $skill_chars chars | $skill_words words | $skill_lines lines" echo " └─ Budget: $TOKEN_WARN_ZONE tokens recommended" fi total_tokens=$((total_tokens + skill_tokens)) total_chars=$((total_chars + skill_chars)) total_words=$((total_words + skill_words)) total_lines=$((total_lines + skill_lines)) file_count=$((file_count + 1)) if [ "$skill_tokens" -gt "$TOKEN_DANGER_ZONE" ]; then _warn "SKILL.md exceeds danger zone (~$skill_tokens tokens > $TOKEN_DANGER_ZONE)" _detail "Consider moving content to references/ for on-demand loading" fi fi # ── Reference files analysis ── if [ -d "references" ]; then if [ "$OUTPUT_MODE" = "terminal" ]; then echo "" echo " $(_bold "Reference Files:")" echo "" printf " $(_dim "%-35s %-12s %-6s %-6s")\n" "FILE" "ZONE" "TOKENS" "LINES" echo " $(_dim "$(printf '%.0s─' $(seq 1 62))")" fi local ref_total=0 local ref_files=0 local largest_ref="" local largest_ref_tokens=0 while IFS= read -r file; do local ref_data ref_tokens ref_chars ref_words ref_lines ref_data=$(estimate_tokens_detailed "$file") ref_tokens=$(echo "$ref_data" | cut -d'|' -f1) ref_chars=$(echo "$ref_data" | cut -d'|' -f2) ref_words=$(echo "$ref_data" | cut -d'|' -f3) ref_lines=$(echo "$ref_data" | cut -d'|' -f4) local zone_indicator zone_indicator=$(token_zone_indicator "$ref_tokens" "$REF_TOKEN_SAFE" "$REF_TOKEN_WARN") local basename_file basename_file=$(basename "$file") [ "$OUTPUT_MODE" = "terminal" ] && printf " %-35s %-12s ~%-6d %d\n" "$basename_file" "$(echo "$zone_indicator" | sed 's/\x1b\[[0-9;]*m//g')" "$ref_tokens" "$ref_lines" || true ref_total=$((ref_total + ref_tokens)) ref_files=$((ref_files + 1)) total_tokens=$((total_tokens + ref_tokens)) total_chars=$((total_chars + ref_chars)) total_words=$((total_words + ref_words)) total_lines=$((total_lines + ref_lines)) file_count=$((file_count + 1)) if [ "$ref_tokens" -gt "$largest_ref_tokens" ]; then largest_ref_tokens=$ref_tokens largest_ref="$basename_file" fi if [ "$ref_tokens" -gt "$REF_TOKEN_DANGER" ]; then _warn "$basename_file is too large (~$ref_tokens tokens > $REF_TOKEN_DANGER)" _detail "Split into base + advanced files (e.g., topic.md + topic-advanced.md)" elif [ "$ref_tokens" -gt "$REF_TOKEN_WARN" ]; then _warn "$basename_file is getting large (~$ref_tokens tokens > $REF_TOKEN_WARN)" _detail "Tighten prose: tables over paragraphs, cross-link shared patterns, trim tutorial content" fi done < <(find references -name '*.md' -type f | sort) if [ "$OUTPUT_MODE" = "terminal" ]; then echo "" echo " $(_dim "Reference subtotal: ~$ref_total tokens across $ref_files files")" if [ -n "$largest_ref" ]; then echo " $(_dim "Largest reference: $largest_ref (~$largest_ref_tokens tokens)")" fi fi fi # ── README analysis ── if [ -f "README.md" ]; then local readme_data readme_tokens readme_lines readme_data=$(estimate_tokens_detailed "README.md") readme_tokens=$(echo "$readme_data" | cut -d'|' -f1) readme_lines=$(echo "$readme_data" | cut -d'|' -f4) if [ "$OUTPUT_MODE" = "terminal" ]; then echo "" echo " $(_bold "README.md") $(_dim "(not loaded by agents, for humans)")" echo " └─ ~$readme_tokens tokens | $readme_lines lines" fi fi # ── Total package summary ── if [ "$OUTPUT_MODE" = "terminal" ]; then echo "" echo " $(_bold "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")" local total_zone_indicator total_zone_indicator=$(token_zone_indicator "$total_tokens" "$TOTAL_TOKEN_SAFE" "$TOTAL_TOKEN_WARN") echo " $(_bold "TOTAL SKILL PACKAGE") $total_zone_indicator" echo "" echo " $(format_tokens_colored "$total_tokens" "$TOTAL_TOKEN_SAFE" "$TOTAL_TOKEN_WARN" "tokens") (estimated)" local total_gauge total_gauge=$(bar_gauge_colored "$total_tokens" "$TOTAL_TOKEN_DANGER" 25 "$TOTAL_TOKEN_SAFE" "$TOTAL_TOKEN_WARN") echo " $total_gauge" echo " $total_chars chars | $total_words words | $total_lines lines | $file_count files" echo "" fi local uses_progressive=false if [ -d "references" ] && [ -f "$SKILL_FILE" ]; then local ref_link_count ref_link_count=$(grep -cE '\]\(references/' "$SKILL_FILE" 2>/dev/null || true) ref_link_count=${ref_link_count:-0} if [ "$ref_link_count" -gt 0 ]; then uses_progressive=true fi fi local ctx_200k_pct ctx_128k_pct skill_only_200k_pct ctx_200k_pct=$(( (total_tokens * 100) / 200000 )) ctx_128k_pct=$(( (total_tokens * 100) / 128000 )) if [ -f "$SKILL_FILE" ]; then skill_only_200k_pct=$(( (_S_BODY_TOKENS * 100) / 200000 )) else skill_only_200k_pct=0 fi if [ "$OUTPUT_MODE" = "terminal" ]; then echo " $(_bold "Context Window Impact:")" echo " $(_dim "Agent tools (Cursor, Windsurf, Copilot) typically use ~200K context")" echo " $(_dim "regardless of the underlying model's max window.")" echo "" echo " ├─ Agent context (200K): ~${ctx_200k_pct}% if all files loaded" echo " ├─ Agent context (128K): ~${ctx_128k_pct}% if all files loaded" if [ "$uses_progressive" = true ]; then echo " ├─ $(_green "SKILL.md only: ~${skill_only_200k_pct}% of 200K") (initial activation cost)" echo " └─ $(_dim "Progressive disclosure — references load on demand, not upfront")" else echo " └─ $(_yellow "Monolithic — entire skill loads at once (no references/ linked)")" fi echo "" fi if [ "$uses_progressive" = true ]; then if [ "$total_tokens" -le "$TOTAL_TOKEN_SAFE" ]; then _pass "Package is well-optimized for token efficiency" elif [ "$total_tokens" -le "$TOTAL_TOKEN_WARN" ]; then _pass "Package size is fine — progressive disclosure loads files on demand" else _info "Large package (~$total_tokens tokens) — ensure no dead/orphaned references" _detail "Total size is informational with progressive disclosure; individual file sizes are the real quality gate" fi else if [ "$total_tokens" -le "$TOKEN_BUDGET" ]; then _pass "Monolithic skill is within token budget" elif [ "$total_tokens" -le "$TOKEN_DANGER_ZONE" ]; then _warn "Monolithic skill (~$total_tokens tokens) exceeds $TOKEN_BUDGET budget" _detail "Move detailed content to references/ for on-demand loading" else _warn "Monolithic skill (~$total_tokens tokens) is very large" _detail "Split into SKILL.md + references/ for progressive disclosure" fi fi } # ── [11] Content Quality Metrics ───────────────────────────────── check_content_quality() { section "Content Quality Metrics" local total_code_blocks=0 local total_headings=0 if [ -f "$SKILL_FILE" ]; then local code_blocks headings internal_links external_links code_blocks=$(safe_count grep -c '```' "$SKILL_FILE") code_blocks=$((code_blocks / 2)) headings=$(safe_count grep -c '^#' "$SKILL_FILE") internal_links=$(safe_count grep -c '\](references/' "$SKILL_FILE") external_links=$(safe_count grep -cE '\]\(https?://' "$SKILL_FILE") echo "" echo " $(_bold "SKILL.md Structure:")" echo " ├─ $headings heading(s) providing navigation" echo " ├─ $code_blocks code example(s)" echo " ├─ $internal_links internal link(s) to references" echo " └─ $external_links external link(s)" total_code_blocks=$code_blocks total_headings=$headings if [ "$headings" -lt 3 ]; then _info "Consider adding more headings for better navigation" fi if [ "$code_blocks" -lt 2 ]; then _info "Consider adding code examples for clarity" fi fi if [ -d "references" ]; then echo "" echo " $(_bold "Reference Files Quality:")" local ref_with_code=0 local ref_without_code=0 local total_ref_headings=0 while IFS= read -r file; do local file_code_blocks file_headings file_code_blocks=$(safe_count grep -c '```' "$file") file_code_blocks=$((file_code_blocks / 2)) file_headings=$(safe_count grep -c '^#' "$file") total_code_blocks=$((total_code_blocks + file_code_blocks)) total_ref_headings=$((total_ref_headings + file_headings)) if [ "$file_code_blocks" -gt 0 ]; then ref_with_code=$((ref_with_code + 1)) else ref_without_code=$((ref_without_code + 1)) fi done < <(find references -name '*.md' -type f) local ref_count ref_count=$(find references -name '*.md' -type f | wc -l | tr -d ' ') echo " ├─ $ref_with_code of $ref_count files contain code examples" echo " ├─ $total_ref_headings total headings across references" echo " └─ $total_code_blocks total code blocks in package" if [ "$ref_without_code" -gt 0 ]; then _info "$ref_without_code reference file(s) have no code examples" _detail "Code examples help agents understand expected patterns" fi fi # Keyword density analysis if [ -f "$SKILL_FILE" ]; then echo "" echo " $(_bold "Trigger Keyword Analysis:")" local compose_mentions kotlin_mentions android_mentions kmp_mentions compose_mentions=$(grep -ioE '(compose|composable|@Composable|jetpack)' "$SKILL_FILE" 2>/dev/null | wc -l | tr -d ' ' || true) kotlin_mentions=$(grep -ioE '(kotlin|coroutine|flow|stateflow|channel)' "$SKILL_FILE" 2>/dev/null | wc -l | tr -d ' ' || true) android_mentions=$(grep -ioE '(android|viewmodel|hilt|koin|room|datastore)' "$SKILL_FILE" 2>/dev/null | wc -l | tr -d ' ' || true) kmp_mentions=$(grep -ioE '(multiplatform|kmp|cmp|commonmain|ios|desktop)' "$SKILL_FILE" 2>/dev/null | wc -l | tr -d ' ' || true) compose_mentions=${compose_mentions:-0} kotlin_mentions=${kotlin_mentions:-0} android_mentions=${android_mentions:-0} kmp_mentions=${kmp_mentions:-0} echo " ├─ Compose/UI: $compose_mentions mentions" echo " ├─ Kotlin/Flow: $kotlin_mentions mentions" echo " ├─ Android/DI: $android_mentions mentions" echo " └─ Multiplatform: $kmp_mentions mentions" local total_keywords=$((compose_mentions + kotlin_mentions + android_mentions + kmp_mentions)) if [ "$total_keywords" -lt 10 ]; then _info "Low keyword density may reduce skill trigger accuracy" _detail "Ensure description and body mention key terms agents should recognize" else _pass "Good keyword coverage for skill triggering ($total_keywords mentions)" fi fi } # ── [12] Agent Metadata ────────────────────────────────────────── check_agents_metadata() { section "Agent Metadata (Codex)" if [ ! -f "agents/openai.yaml" ]; then _info "No agents/openai.yaml — optional, configures Codex app UI and policy" return fi _pass "agents/openai.yaml present" if grep -q '^interface:' agents/openai.yaml; then if grep -q 'display_name:' agents/openai.yaml; then local display_name display_name=$(grep 'display_name:' agents/openai.yaml | sed 's/.*display_name:[[:space:]]*//' | tr -d '"') _pass "display_name: '$display_name'" else _info "No display_name — Codex uses skill name" fi if grep -q 'short_description:' agents/openai.yaml; then _pass "short_description set" else _info "No short_description — Codex uses SKILL.md description" fi if grep -q 'default_prompt:' agents/openai.yaml; then _pass "default_prompt set" fi if grep -q 'brand_color:' agents/openai.yaml; then _pass "brand_color set" fi local icon_fields icon_fields=$(grep -oE '(icon_small|icon_large):[[:space:]]*"[^"]+"' agents/openai.yaml 2>/dev/null || true) if [ -n "$icon_fields" ]; then echo "$icon_fields" | while IFS= read -r line; do local field icon_path field=$(echo "$line" | cut -d: -f1 | tr -d ' ') icon_path=$(echo "$line" | sed 's/.*"\(.*\)"/\1/') if [ -f "$icon_path" ]; then _pass "$field: $icon_path" else _warn "$field: '$icon_path' → file not found" ci_annotate "warning" "file=agents/openai.yaml::$field file missing: $icon_path" fi done fi fi if grep -q '^policy:' agents/openai.yaml; then if grep -q 'allow_implicit_invocation:' agents/openai.yaml; then local implicit implicit=$(grep 'allow_implicit_invocation:' agents/openai.yaml | sed 's/.*allow_implicit_invocation:[[:space:]]*//') _pass "allow_implicit_invocation: $implicit" fi fi if grep -q '^dependencies:' agents/openai.yaml; then _pass "dependencies declared" fi } # ── [13] Security Scan ─────────────────────────────────────────── check_security() { section "Security Scan" local secret_count=0 local path_count=0 local cmd_count=0 # ── Secrets detection (11 patterns from skill-tools / skill-validator) ── local -a secret_patterns=( 'sk-[a-zA-Z0-9]{20,}' 'sk_live_[a-zA-Z0-9]+' 'sk_test_[a-zA-Z0-9]+' 'ghp_[a-zA-Z0-9]{36}' 'gho_[a-zA-Z0-9]{36}' 'ghu_[a-zA-Z0-9]{36}' 'ghs_[a-zA-Z0-9]{36}' 'ghr_[a-zA-Z0-9]{36}' 'xoxb-[a-zA-Z0-9-]+' 'xoxp-[a-zA-Z0-9-]+' 'AKIA[0-9A-Z]{16}' ) while IFS= read -r file; do for pattern in "${secret_patterns[@]}"; do local matches matches=$(grep -nE "$pattern" "$file" 2>/dev/null | head -1 || true) if [ -n "$matches" ]; then secret_count=$((secret_count + 1)) local line_num line_num=$(echo "$matches" | cut -d: -f1) _error "$file:$line_num — potential secret/API key detected" _detail "Pattern: $pattern" ci_annotate "error" "file=$file,line=$line_num::Potential secret detected" fi done # PEM private key headers local pem_match pem_match=$(grep -n 'BEGIN.*PRIVATE KEY' "$file" 2>/dev/null | head -1 || true) if [ -n "$pem_match" ]; then secret_count=$((secret_count + 1)) local line_num line_num=$(echo "$pem_match" | cut -d: -f1) _error "$file:$line_num — private key header detected" ci_annotate "error" "file=$file,line=$line_num::Private key detected" fi # JWT tokens local jwt_match jwt_match=$(grep -nE 'eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.' "$file" 2>/dev/null | head -1 || true) if [ -n "$jwt_match" ]; then secret_count=$((secret_count + 1)) local line_num line_num=$(echo "$jwt_match" | cut -d: -f1) _error "$file:$line_num — JWT token detected" ci_annotate "error" "file=$file,line=$line_num::JWT token detected" fi done < <(find . -name '*.md' -not -path './.git/*' | sort) _S_SECRETS=$secret_count if [ "$secret_count" -eq 0 ]; then _pass "No API keys or secrets detected (13 patterns checked)" fi # ── Hardcoded absolute paths ── echo "" _detail "Checking for hardcoded paths..." while IFS= read -r file; do local path_matches path_matches=$(grep -nE '(/Users/[a-zA-Z]|/home/[a-zA-Z]|C:\\Users\\)' "$file" 2>/dev/null || true) if [ -n "$path_matches" ]; then while IFS= read -r match; do path_count=$((path_count + 1)) local line_num line_num=$(echo "$match" | cut -d: -f1) _warn "$file:$line_num — hardcoded absolute path" _detail "$(echo "$match" | cut -d: -f2- | sed 's/^[[:space:]]*//' | head -c 80)" done <<< "$path_matches" fi done < <(find . -name '*.md' -not -path './.git/*' | sort) _S_HARDCODED=$path_count if [ "$path_count" -eq 0 ]; then _pass "No hardcoded absolute paths" fi # ── Dangerous commands ── echo "" _detail "Checking for dangerous commands..." local -a dangerous_patterns=( 'rm -rf /' 'sudo rm ' 'DROP TABLE' 'DROP DATABASE' 'chmod 777' ) local -a dangerous_regex=( 'curl .*\| *sh' 'wget .*\| *sh' ) while IFS= read -r file; do for pattern in "${dangerous_patterns[@]}"; do local matches matches=$(grep -nF "$pattern" "$file" 2>/dev/null | head -1 || true) if [ -n "$matches" ]; then cmd_count=$((cmd_count + 1)) local line_num line_num=$(echo "$matches" | cut -d: -f1) _warn "$file:$line_num — potentially dangerous command" _detail "Matched: $pattern" ci_annotate "warning" "file=$file,line=$line_num::Dangerous command pattern" fi done for pattern in "${dangerous_regex[@]}"; do local matches matches=$(grep -nE "$pattern" "$file" 2>/dev/null | head -1 || true) if [ -n "$matches" ]; then cmd_count=$((cmd_count + 1)) local line_num line_num=$(echo "$matches" | cut -d: -f1) _warn "$file:$line_num — potentially dangerous command" _detail "Matched: $pattern" ci_annotate "warning" "file=$file,line=$line_num::Dangerous command pattern" fi done done < <(find . -name '*.md' -not -path './.git/*' | sort) _S_DANGEROUS=$cmd_count if [ "$cmd_count" -eq 0 ]; then _pass "No dangerous shell commands detected" fi echo "" echo " $(_dim " ┌─────────────────────────────────────────────┐")" printf " $(_dim " │") Secrets: %-4s Paths: %-4s Commands: %-4s $(_dim "│")\n" "$secret_count" "$path_count" "$cmd_count" echo " $(_dim " └─────────────────────────────────────────────┘")" } # ── [14] Heading Hierarchy & Duplicates ────────────────────────── check_heading_hierarchy() { section "Heading Hierarchy & Duplicates" local skip_issues=0 local dup_issues=0 local files_checked=0 while IFS= read -r file; do files_checked=$((files_checked + 1)) local prev_level=0 local line_num=0 local headings_seen="" while IFS= read -r line; do line_num=$((line_num + 1)) case "$line" in '#'*) local stripped_prefix stripped_prefix="${line%%[^#]*}" local level=${#stripped_prefix} local text text=$(echo "$line" | sed 's/^#* *//') # MD001: heading level increment check if [ "$prev_level" -gt 0 ] && [ "$level" -gt "$((prev_level + 1))" ]; then skip_issues=$((skip_issues + 1)) _warn "$file:$line_num — heading skip H$prev_level → H$level" _detail "$text" ci_annotate "warning" "file=$file,line=$line_num::Heading level skip (H$prev_level to H$level)" fi prev_level=$level # MD024: duplicate heading check (same level, same text) local key="${level}:${text}" if echo "$headings_seen" | grep -qF "<<${key}>>"; then dup_issues=$((dup_issues + 1)) _warn "$file:$line_num — duplicate heading: '$text' (H$level)" ci_annotate "warning" "file=$file,line=$line_num::Duplicate heading '$text'" fi headings_seen="${headings_seen}<<${key}>>" ;; esac done < "$file" done < <(find . -name '*.md' -not -path './.git/*' | sort) if [ "$skip_issues" -eq 0 ]; then _pass "No heading level skips across $files_checked files (MD001)" fi if [ "$dup_issues" -eq 0 ]; then _pass "No duplicate sibling headings across $files_checked files (MD024)" fi echo "" _detail "Checked $files_checked files | Level skips: $skip_issues | Duplicates: $dup_issues" } # ── [15] Quality Score ─────────────────────────────────────────── compute_quality_score() { section "Quality Score" local desc_score=0 local clarity_score=0 local spec_score=0 local progressive_score=0 local security_score=10 _check_mark() { if [ "$1" = "1" ]; then printf "%s" "$(_green "✓")"; else printf "%s" "$(_red "✗")"; fi } # ── Description Quality (30 pts) ── local d1=0 d2=0 d3=0 d4=0 d5=0 d6=0 d7=0 [ "$_S_HAS_DESC" = true ] && { desc_score=$((desc_score + 5)); d1=1; } [ "$_S_DESC_LEN" -ge "$DESC_MIN_USEFUL" ] && { desc_score=$((desc_score + 3)); d2=1; } [ "$_S_DESC_WHAT" = true ] && { desc_score=$((desc_score + 5)); d3=1; } [ "$_S_DESC_WHEN" = true ] && { desc_score=$((desc_score + 5)); d4=1; } [ "$_S_DESC_FIRST_PERSON" = false ] && { desc_score=$((desc_score + 4)); d5=1; } [ "$_S_DESC_GENERIC" = false ] && { desc_score=$((desc_score + 4)); d6=1; } [ "$_S_DESC_NEGATIVES" = true ] && { desc_score=$((desc_score + 4)); d7=1; } # ── Instruction Clarity (25 pts) ── local c1=0 c2=0 c3=0 c4=0 c5=0 c6=0 [ "$_S_CODE_BLOCKS" -ge 1 ] && { clarity_score=$((clarity_score + 5)); c1=1; } [ "$_S_CODE_BLOCKS" -ge 3 ] && { clarity_score=$((clarity_score + 5)); c2=1; } [ "$_S_HEADINGS" -ge 3 ] && { clarity_score=$((clarity_score + 5)); c3=1; } [ "$_S_HEADINGS" -ge 5 ] && { clarity_score=$((clarity_score + 3)); c4=1; } [ "$_S_REF_LINKS" -gt 0 ] && { clarity_score=$((clarity_score + 4)); c5=1; } [ "$_S_REF_LINKS" -ge 5 ] && { clarity_score=$((clarity_score + 3)); c6=1; } # ── Spec Compliance (20 pts) ── local s1=0 s2=0 s3=0 s4=0 s5=0 s6=0 [ "$_S_HAS_NAME" = true ] && { spec_score=$((spec_score + 4)); s1=1; } [ "$_S_NAME_OK" = true ] && { spec_score=$((spec_score + 4)); s2=1; } [ "$_S_HAS_DESC" = true ] && { spec_score=$((spec_score + 4)); s3=1; } [ "$_S_BODY_TOKENS" -le "$TOKEN_BUDGET" ] && { spec_score=$((spec_score + 4)); s4=1; } [ "$_S_BODY_LINES" -le "$BODY_MAX_LINES" ] && { spec_score=$((spec_score + 2)); s5=1; } [ "$_S_FENCES_OK" = true ] && { spec_score=$((spec_score + 2)); s6=1; } # ── Progressive Disclosure (15 pts) ── local p1=0 p2=0 p3=0 p4=0 [ "$_S_HAS_REFS_DIR" = true ] && { progressive_score=$((progressive_score + 5)); p1=1; } [ "$_S_REFS_LINKED" -gt 0 ] && { progressive_score=$((progressive_score + 5)); p2=1; } if [ "$_S_REFS_TOTAL" -gt 0 ] && [ "$_S_REFS_LINKED" -eq "$_S_REFS_TOTAL" ]; then progressive_score=$((progressive_score + 3)); p3=1 fi [ "$_S_BODY_LINES" -lt "$BODY_MAX_LINES" ] && { progressive_score=$((progressive_score + 2)); p4=1; } # ── Security (10 pts — start at 10, deduct for issues) ── local x1=1 x2=1 x3=1 if [ "$_S_SECRETS" -gt 0 ]; then security_score=$((security_score - 5)); x1=0; fi if [ "$_S_HARDCODED" -gt 0 ]; then security_score=$((security_score - 3)); x2=0; fi if [ "$_S_DANGEROUS" -gt 0 ]; then security_score=$((security_score - 2)); x3=0; fi [ "$security_score" -lt 0 ] && security_score=0 local total_score=$((desc_score + clarity_score + spec_score + progressive_score + security_score)) QUALITY_SCORE=$total_score QUALITY_GRADE=$(letter_grade "$total_score") QUALITY_DESC=$desc_score QUALITY_CLARITY=$clarity_score QUALITY_SPEC=$spec_score QUALITY_PROGRESSIVE=$progressive_score QUALITY_SECURITY=$security_score # ── Display: clean bar chart overview (terminal only) ── if [ "$OUTPUT_MODE" = "terminal" ]; then _dim_bar() { local name="$1" score="$2" max="$3" local gauge pct if [ "$max" -le 0 ]; then pct=0 else pct=$(( (score * 100) / max )); fi gauge=$(bar_gauge "$score" "$max" 15) local colored_gauge if [ "$pct" -ge 80 ]; then colored_gauge=$(_green "$gauge") elif [ "$pct" -ge 60 ]; then colored_gauge=$(_yellow "$gauge") else colored_gauge=$(_red "$gauge") fi if [ "$score" -eq "$max" ]; then printf " %-24s %s %2d / %-2d $(_green "✓")\n" "$name" "$colored_gauge" "$score" "$max" else printf " %-24s %s %2d / %-2d\n" "$name" "$colored_gauge" "$score" "$max" fi } echo "" echo " $(_bold "Score Overview:")" echo "" _dim_bar "Description Quality" "$desc_score" "30" _dim_bar "Instruction Clarity" "$clarity_score" "25" _dim_bar "Spec Compliance" "$spec_score" "20" _dim_bar "Progressive Disclosure" "$progressive_score" "15" _dim_bar "Security" "$security_score" "10" echo "" echo " $(_dim "$(printf '%.0s─' $(seq 1 52))")" local total_gauge total_gauge=$(bar_gauge "$total_score" 100 20) local colored_total if [ "$total_score" -ge 85 ]; then colored_total=$(_bold_green "$total_gauge") elif [ "$total_score" -ge 65 ]; then colored_total=$(_bold_yellow "$total_gauge") else colored_total=$(_bold_red "$total_gauge") fi printf " %-24s %s %d/100 Grade: %s\n" "$(_bold "TOTAL")" "$colored_total" "$total_score" "$(grade_color "$total_score")" fi # end terminal-only display # ── Collect failed sub-checks with sources and actionable fixes ── # Sources verified against: # [spec] agentskills.io/specification # [official] agentskills.io/skill-creation/best-practices # agentskills.io/skill-creation/optimizing-descriptions # [community] mdskills.ai/docs/skill-best-practices local fix_count=0 local -a fix_labels=() local -a fix_sources=() local -a fix_dims=() [ "$d1" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Add a 'description:' field to frontmatter (+5)"); fix_sources+=("spec: required field — agentskills.io/specification#description-field"); } [ "$d2" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Write a longer description (>=${DESC_MIN_USEFUL} chars) (+3)"); fix_sources+=("official: 'A few sentences to a short paragraph' — agentskills.io/skill-creation/optimizing-descriptions"); } [ "$d3" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Add action verbs: build, generate, validate, extract, etc. (+5)"); fix_sources+=("spec: 'Describes what the skill does' — agentskills.io/specification#description-field"); } [ "$d4" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Add 'Use when...' trigger phrase to description (+5)"); fix_sources+=("official: 'Use imperative phrasing: Use this skill when...' — agentskills.io/skill-creation/optimizing-descriptions"); } [ "$d5" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Rewrite in third person — remove 'I can/will/help' (+4)"); fix_sources+=("community: 'first person causes discovery problems' — mdskills.ai/docs/skill-best-practices"); } [ "$d6" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Replace generic verbs (manage, handle) with specific ones (+4)"); fix_sources+=("community: 'Be specific about format, operation, trigger' — mdskills.ai/docs/skill-best-practices"); } [ "$d7" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Description"); fix_labels+=("Add boundary markers: 'Not for...', 'Do not use when...' (+4)"); fix_sources+=("official: 'Add specificity about what the skill does not do' — agentskills.io/skill-creation/optimizing-descriptions"); } [ "$c1" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Clarity"); fix_labels+=("Add at least 1 fenced code block to SKILL.md body (+5)"); fix_sources+=("spec: recommended section 'Examples of inputs and outputs' — agentskills.io/specification#body-content"); } [ "$c2" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Clarity"); fix_labels+=("Add 3+ code examples to SKILL.md (+5)"); fix_sources+=("official: 'a working example tends to outperform exhaustive documentation' — agentskills.io/skill-creation/best-practices"); } [ "$c3" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Clarity"); fix_labels+=("Add 3+ markdown headings for navigation (+5)"); fix_sources+=("community: 'structure for agent scanning' — mdskills.ai/docs/skill-best-practices"); } [ "$c4" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Clarity"); fix_labels+=("Add 5+ headings for deeper structure (+3)"); fix_sources+=("community: structured content aids agent navigation — mdskills.ai/docs/skill-best-practices"); } [ "$c5" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Clarity"); fix_labels+=("Link to at least one reference file from SKILL.md body (+4)"); fix_sources+=("official: 'tell the agent when to load each file' — agentskills.io/skill-creation/best-practices"); } [ "$c6" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Clarity"); fix_labels+=("Link to 5+ reference files for full coverage (+3)"); fix_sources+=("official: progressive disclosure via on-demand loading — agentskills.io/specification#progressive-disclosure"); } [ "$s4" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Spec"); fix_labels+=("Reduce SKILL.md to <$TOKEN_BUDGET tokens — move content to references/ (+4)"); fix_sources+=("spec: '<5000 tokens recommended' — agentskills.io/specification#progressive-disclosure"); } [ "$s5" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Spec"); fix_labels+=("Reduce SKILL.md body to <$BODY_MAX_LINES lines (+2)"); fix_sources+=("spec: 'under 500 lines' — agentskills.io/specification#progressive-disclosure"); } [ "$s6" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Spec"); fix_labels+=("Fix unclosed code fences in markdown files (+2)"); fix_sources+=("spec: valid markdown required for agent parsing"); } [ "$p1" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Disclosure"); fix_labels+=("Create a references/ directory for detailed docs (+5)"); fix_sources+=("spec: optional directory for on-demand loading — agentskills.io/specification#references"); } [ "$p2" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Disclosure"); fix_labels+=("Add links to reference files from SKILL.md body (+5)"); fix_sources+=("official: 'tell the agent when to load each file' — agentskills.io/skill-creation/best-practices"); } [ "$p3" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Disclosure"); fix_labels+=("Link all reference files from SKILL.md — orphaned files found (+3)"); fix_sources+=("spec: 'use relative paths from skill root' — agentskills.io/specification#file-references"); } [ "$p4" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Disclosure"); fix_labels+=("Keep body under $BODY_MAX_LINES lines (+2)"); fix_sources+=("spec: 'under 500 lines' — agentskills.io/specification#progressive-disclosure"); } [ "$x1" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Security"); fix_labels+=("Remove detected API keys/secrets from markdown files (+5)"); fix_sources+=("community: 'Never hardcode credentials' — mdskills.ai/docs/skill-best-practices"); } [ "$x2" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Security"); fix_labels+=("Replace hardcoded paths (/Users/...) with relative paths (+3)"); fix_sources+=("spec: 'use relative paths from the skill root' — agentskills.io/specification#file-references"); } [ "$x3" = "0" ] && { fix_count=$((fix_count+1)); fix_dims+=("Security"); fix_labels+=("Remove or guard dangerous commands (rm -rf, chmod 777) (+2)"); fix_sources+=("community: 'Shell commands need guardrails' — mdskills.ai/docs/skill-best-practices"); } # ── Display: "How to Improve" section with sourced fixes (terminal only) ── if [ "$OUTPUT_MODE" = "terminal" ]; then if [ "$fix_count" -gt 0 ]; then echo "" echo "" echo " $(_bold "┌────────────────────────────────────────────────────────┐")" echo " $(_bold "│") $(_bold "HOW TO REACH 100/100") $(_dim "($fix_count items, by impact)") $(_bold "│")" echo " $(_bold "└────────────────────────────────────────────────────────┘")" local prev_dim="" local i for ((i=0; i/dev/null || true) has_warnings=$(grep -c "^WARN|" "$FINDINGS_FILE" 2>/dev/null || true) has_info=$(grep -c "^INFO|" "$FINDINGS_FILE" 2>/dev/null || true) if [ "${has_errors:-0}" -gt 0 ]; then echo "### Errors" echo "" grep "^ERROR|" "$FINDINGS_FILE" | while IFS='|' read -r _ sec msg; do echo "- **$sec**: $msg" done echo "" fi if [ "${has_warnings:-0}" -gt 0 ]; then echo "### Warnings" echo "" grep "^WARN|" "$FINDINGS_FILE" | while IFS='|' read -r _ sec msg; do echo "- **$sec**: $msg" done echo "" fi if [ "${has_info:-0}" -gt 0 ]; then echo "### Suggestions" echo "" grep "^INFO|" "$FINDINGS_FILE" | while IFS='|' read -r _ sec msg; do echo "- **$sec**: $msg" done echo "" fi fi echo "---" echo "" echo "_Validated against [agentskills.io/specification](https://agentskills.io/specification) | Scanner v4.0 | $(date '+%Y-%m-%d %H:%M:%S')_" } print_score_only() { if [ "$NO_COLOR" = "1" ]; then echo "$QUALITY_SCORE/100 $QUALITY_GRADE" else echo "$QUALITY_SCORE/100 $(grade_color "$QUALITY_SCORE")" fi [ "$ERRORS" -gt 0 ] && exit 1 exit 0 } # ════════════════════════════════════════════════════════════════════ # REPORT (terminal) # ════════════════════════════════════════════════════════════════════ print_report() { echo "" echo "" local elapsed=$(( SECONDS - SCAN_START )) _pad() { local text="$1" width="$2" local visible visible=$(echo "$text" | sed 's/\x1b\[[0-9;]*m//g') local pad_len=$(( width - ${#visible} )) if [ "$pad_len" -lt 0 ]; then pad_len=0; fi printf '%s%*s' "$text" "$pad_len" "" } local W=58 local line line=$(printf '%.0s═' $(seq 1 $W)) local thin_line thin_line=$(printf '%.0s─' $(seq 1 $W)) # ── Summary box ── echo " $(_bold "╔${line}╗")" echo " $(_bold "║")$(_pad "" $W)$(_bold "║")" echo " $(_bold "║")$(_pad " AGENT SKILL SCAN REPORT " $W)$(_bold "║")" echo " $(_bold "║")$(_pad " Scanner v4.0 " $W)$(_bold "║")" echo " $(_bold "║")$(_pad "" $W)$(_bold "║")" echo " $(_bold "╠${line}╣")" echo " ║$(_pad "" $W)║" echo " ║ $(_pad "Checks run : $CHECKS_RUN sections" $(( W - 2 )))║" echo " ║ $(_pad "$(_red "Errors") : $ERRORS" $(( W - 2 )))║" echo " ║ $(_pad "$(_yellow "Warnings") : $WARNINGS" $(( W - 2 )))║" echo " ║ $(_pad "$(_blue "Info") : $INFO_COUNT" $(( W - 2 )))║" echo " ║ $(_pad "Duration : ${elapsed}s" $(( W - 2 )))║" echo " ║$(_pad "" $W)║" echo " $(_bold "╠${line}╣")" echo " ║$(_pad "" $W)║" local result_text if [ "$ERRORS" -gt 0 ]; then result_text=$(_bold_red "FAIL") elif [ "$WARNINGS" -gt 0 ]; then result_text=$(_bold_yellow "PASS with warnings") else result_text=$(_bold_green "ALL CLEAR") fi echo " ║ $(_pad "Result : $result_text" $(( W - 2 )))║" echo " ║ $(_pad "Quality Score : $QUALITY_SCORE/100 ($(grade_color "$QUALITY_SCORE"))" $(( W - 2 )))║" echo " ║$(_pad "" $W)║" echo " $(_bold "╠${line}╣")" echo " ║$(_pad "" $W)║" echo " ║ $(_pad "Description : $QUALITY_DESC/30" $(( W - 2 )))║" echo " ║ $(_pad "Clarity : $QUALITY_CLARITY/25" $(( W - 2 )))║" echo " ║ $(_pad "Spec : $QUALITY_SPEC/20" $(( W - 2 )))║" echo " ║ $(_pad "Progressive : $QUALITY_PROGRESSIVE/15" $(( W - 2 )))║" echo " ║ $(_pad "Security : $QUALITY_SECURITY/10" $(( W - 2 )))║" echo " ║$(_pad "" $W)║" echo " $(_bold "╚${line}╝")" # ── Detailed findings ── local total_findings total_findings=$(wc -l < "$FINDINGS_FILE" | tr -d ' ') if [ "$total_findings" -gt 0 ]; then echo "" echo " $(_bold "┌${thin_line}┐")" echo " $(_bold "│")$(_pad " DETAILED FINDINGS " $W)$(_bold "│")" echo " $(_bold "└${thin_line}┘")" _print_findings_by_severity() { local severity="$1" label="$2" color_fn="$3" local matches matches=$(grep "^${severity}|" "$FINDINGS_FILE" 2>/dev/null || true) [ -z "$matches" ] && return local count count=$(echo "$matches" | wc -l | tr -d ' ') echo "" echo " $($color_fn "$label ($count)")" echo " $(_dim "$(printf '%.0s─' $(seq 1 56))")" local prev_section="" while IFS='|' read -r _ sec msg; do if [ "$sec" != "$prev_section" ]; then echo "" echo " $(_dim "[$sec]")" prev_section="$sec" fi echo " $($color_fn "▸") $msg" done <<< "$matches" } _print_findings_by_severity "ERROR" "ERRORS" "_red" _print_findings_by_severity "WARN" "WARNINGS" "_yellow" _print_findings_by_severity "INFO" "SUGGESTIONS" "_blue" echo "" echo " $(_dim "$(printf '%.0s─' $(seq 1 60))")" else echo "" echo " $(_bold_green "No findings — skill package is in perfect shape.")" fi echo "" echo " $(_dim "Validated against agentskills.io/specification")" echo " $(_dim "Scored using skill-tools 5-dimension rubric (0-100)")" echo "" # ── CI job summary ── if [ "$CI" = "true" ]; then { echo "## Agent Skill Scan Report" echo "" if [ "$ERRORS" -gt 0 ]; then echo "**FAIL** — $ERRORS error(s), $WARNINGS warning(s)" elif [ "$WARNINGS" -gt 0 ]; then echo "**PASS with $WARNINGS warning(s)**" else echo "**All checks passed**" fi echo "" echo "**Quality Score: $QUALITY_SCORE/100 (Grade: $QUALITY_GRADE)**" echo "" echo "| Metric | Count |" echo "|--------|-------|" echo "| Checks | $CHECKS_RUN |" echo "| Errors | $ERRORS |" echo "| Warnings | $WARNINGS |" echo "| Info | $INFO_COUNT |" echo "| Duration | ${elapsed}s |" echo "" echo "| Dimension | Score |" echo "|-----------|-------|" echo "| Description Quality | $QUALITY_DESC/30 |" echo "| Instruction Clarity | $QUALITY_CLARITY/25 |" echo "| Spec Compliance | $QUALITY_SPEC/20 |" echo "| Progressive Disclosure | $QUALITY_PROGRESSIVE/15 |" echo "| Security | $QUALITY_SECURITY/10 |" echo "| **Total** | **$QUALITY_SCORE/100 ($QUALITY_GRADE)** |" if [ "$total_findings" -gt 0 ]; then echo "" echo "### Findings" echo "" echo "
" echo "Click to expand ($total_findings findings)" echo "" _ci_findings() { local severity="$1" icon="$2" local matches matches=$(grep "^${severity}|" "$FINDINGS_FILE" 2>/dev/null || true) [ -z "$matches" ] && return while IFS='|' read -r _ sec msg; do echo "- ${icon} **${sec}**: ${msg}" done <<< "$matches" } _ci_findings "ERROR" "x" _ci_findings "WARN" "!" _ci_findings "INFO" "i" echo "" echo "
" fi echo "" echo "_Validated against [agentskills.io/specification](https://agentskills.io/specification) | Score: $QUALITY_SCORE/100 ($QUALITY_GRADE)_" } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" fi } # ════════════════════════════════════════════════════════════════════ # MAIN # ════════════════════════════════════════════════════════════════════ show_help() { echo "" echo " $(_bold "Agent Skill Scanner v4")" echo "" echo " Validates skill packages against the agentskills.io specification" echo " and community best practices for AI agent skills." echo " Computes a quality score (0-100) across 5 dimensions." echo "" echo " $(_bold "Usage:")" echo " ./scripts/validate.sh Run full scan (terminal)" echo " ./scripts/validate.sh --json Output as JSON" echo " ./scripts/validate.sh --md Output as Markdown report" echo " ./scripts/validate.sh --score-only Print score and grade only" echo " ./scripts/validate.sh --help Show this help" echo "" echo " $(_bold "Output Modes:")" echo " $(_cyan "--json") Machine-readable JSON (pipe to jq, feed to web tools)" echo " $(_cyan "--md") Markdown report (save to file, paste in PRs/docs)" echo " $(_cyan "--score-only") Quick score check (e.g., in pre-commit hooks)" echo " $(_dim "(default)") Rich terminal output with colors and bar charts" echo "" echo " $(_bold "Environment:")" echo " CI=true Emit GitHub Actions annotations + job summary" echo " NO_COLOR=1 Disable colored output" echo "" echo " $(_bold "Checks (15 sections, 20+ individual checks):")" echo " $(_cyan " 1.") Skill structure Required/optional files and directories" echo " $(_cyan " 2.") Frontmatter & desc name, description, voice, verbs, negative triggers" echo " $(_cyan " 3.") Body & disclosure Line count, token budget, structure, code examples" echo " $(_cyan " 4.") Internal links All links resolve to existing files" echo " $(_cyan " 5.") Reference files Linked, orphaned, empty, and missing detection" echo " $(_cyan " 6.") Markdown syntax Unclosed code blocks" echo " $(_cyan " 7.") Reference nesting No deep cross-reference chains" echo " $(_cyan " 8.") Scripts Executable permissions, documentation" echo " $(_cyan " 9.") Repository hygiene Sensitive files, development artifacts" echo " $(_cyan "10.") Token budget Per-file and total token analysis with zones" echo " $(_cyan "11.") Content quality Code examples, headings, keyword density" echo " $(_cyan "12.") Agent metadata agents/openai.yaml validation" echo " $(_cyan "13.") Security scan API keys, secrets, hardcoded paths, dangerous commands" echo " $(_cyan "14.") Heading hierarchy Level skip detection (MD001), duplicate headings (MD024)" echo " $(_cyan "15.") Quality score 0-100 score across 5 dimensions with letter grade" echo "" echo " $(_bold "Quality Score Dimensions:")" echo " Description Quality (30 pts) Length, specificity, voice, verbs, triggers" echo " Instruction Clarity (25 pts) Code blocks, headings, reference links" echo " Spec Compliance (20 pts) Required fields, name format, token/line limits" echo " Progressive Discl. (15 pts) References dir, linking, body size" echo " Security (10 pts) No secrets, paths, or dangerous commands" echo "" echo " $(_bold "Letter Grades:")" echo " $(_bold_green "A+") 95+ $(_bold_green "A") 90+ $(_bold_green "A-") 85+ $(_bold_yellow "B+") 80+ $(_bold_yellow "B") 75+" echo " $(_bold_yellow "B-") 70+ $(_bold_yellow "C+") 65+ $(_bold_red "C") 60+ $(_bold_red "C-") 55+ $(_bold_red "D") 50+ $(_bold_red "F") <50" echo "" echo " $(_bold "Token Zones:")" echo " $(_green "[SAFE]") Well within budget, efficient" echo " $(_yellow "[WARN]") At or near recommended limits" echo " $(_red "[HIGH]") Exceeding recommendations, consider optimization" echo "" echo " $(_bold "Token Estimation:")" echo " Uses chars/4 approximation (industry standard for tiktoken cl100k_base)" echo " SKILL.md budget: <5000 tokens | Reference: <4000 tokens each" echo " Total package: <50000 tokens recommended" echo "" echo " $(_bold "Severity:")" echo " $(_red "✗ ERROR") Spec violation or broken content — must fix" echo " $(_yellow "! WARN ") Best practice issue — should fix" echo " $(_blue "ℹ INFO ") Suggestion — nice to have" echo " $(_green "✓ PASS ") Check passed" echo "" echo " $(_bold "References:")" echo " Spec: https://agentskills.io/specification" echo " Scoring: https://agentskills.io/skill-creation/best-practices" echo " Tokens: https://agentpatterns.ai/context-engineering/context-budget-allocation/" echo "" } _suppress_terminal() { [ "$OUTPUT_MODE" != "terminal" ] } main() { # Parse CLI flags while [ $# -gt 0 ]; do case "$1" in --help|-h) show_help; exit 0 ;; --json) OUTPUT_MODE="json"; NO_COLOR=1 ;; --md|--markdown) OUTPUT_MODE="markdown"; NO_COLOR=1 ;; --score-only|--score) OUTPUT_MODE="score-only" ;; *) echo "Unknown flag: $1"; echo "Run with --help for usage."; exit 2 ;; esac shift done if ! _suppress_terminal; then echo "" echo " $(_bold "╔══════════════════════════════════════════════════════════╗")" echo " $(_bold "║") $(_bold "║")" echo " $(_bold "║") $(_bold_cyan "A G E N T S K I L L S C A N N E R") $(_bold "║")" echo " $(_bold "║") $(_dim "v 4 . 0") $(_bold "║")" echo " $(_bold "║") $(_bold "║")" echo " $(_bold "╚══════════════════════════════════════════════════════════╝")" echo "" if [ "$CI" = "true" ]; then _detail "Mode : CI (GitHub Actions annotations + job summary)" else _detail "Mode : Local" fi _detail "Skill: $(pwd)" _detail "Spec : agentskills.io/specification" _detail "Date : $(date '+%Y-%m-%d %H:%M:%S')" echo "" echo " $(_dim "Running $TOTAL_CHECKS sections with 20+ individual checks...")" fi if [ "$OUTPUT_MODE" != "terminal" ]; then exec 3>&1 1>/dev/null fi check_structure check_frontmatter check_body check_links check_references check_markdown check_reference_depth check_scripts check_repo_hygiene check_token_budget check_content_quality check_agents_metadata check_security check_heading_hierarchy compute_quality_score if [ "$OUTPUT_MODE" != "terminal" ]; then exec 1>&3 3>&- fi case "$OUTPUT_MODE" in json) print_json ;; markdown) print_markdown ;; score-only) print_score_only ;; *) print_report ;; esac [ "$ERRORS" -gt 0 ] && exit 1 exit 0 } main "$@"