mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory. Co-authored-by: Junie <junie@jetbrains.com>
2205 lines
85 KiB
Bash
Executable File
2205 lines
85 KiB
Bash
Executable File
#!/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<filled; i++)); do bar+="█"; done
|
||
for ((i=0; i<empty; i++)); do bar+="░"; done
|
||
echo "$bar"
|
||
}
|
||
|
||
bar_gauge_colored() {
|
||
local value="$1" max="$2" width="${3:-20}" safe="${4:-0}" warn="${5:-0}"
|
||
local pct gauge
|
||
if [ "$max" -le 0 ]; then pct=0
|
||
else pct=$(( (value * 100) / max )); fi
|
||
[ "$pct" -gt 100 ] && pct=100
|
||
gauge=$(bar_gauge "$value" "$max" "$width")
|
||
|
||
if [ "$safe" -gt 0 ] && [ "$warn" -gt 0 ]; then
|
||
local zone
|
||
zone=$(token_zone_color "$value" "$safe" "$warn")
|
||
case "$zone" in
|
||
green) printf "%s %s" "$(_green "$gauge")" "$(_green "${pct}%")" ;;
|
||
yellow) printf "%s %s" "$(_yellow "$gauge")" "$(_yellow "${pct}%")" ;;
|
||
red) printf "%s %s" "$(_red "$gauge")" "$(_red "${pct}%")" ;;
|
||
esac
|
||
else
|
||
printf "%s %d%%" "$gauge" "$pct"
|
||
fi
|
||
}
|
||
|
||
letter_grade() {
|
||
local score="$1"
|
||
if [ "$score" -ge 95 ]; then echo "A+"
|
||
elif [ "$score" -ge 90 ]; then echo "A"
|
||
elif [ "$score" -ge 85 ]; then echo "A-"
|
||
elif [ "$score" -ge 80 ]; then echo "B+"
|
||
elif [ "$score" -ge 75 ]; then echo "B"
|
||
elif [ "$score" -ge 70 ]; then echo "B-"
|
||
elif [ "$score" -ge 65 ]; then echo "C+"
|
||
elif [ "$score" -ge 60 ]; then echo "C"
|
||
elif [ "$score" -ge 55 ]; then echo "C-"
|
||
elif [ "$score" -ge 50 ]; then echo "D"
|
||
else echo "F"
|
||
fi
|
||
}
|
||
|
||
grade_color() {
|
||
local score="$1" grade
|
||
grade=$(letter_grade "$score")
|
||
if [ "$score" -ge 85 ]; then printf "%s" "$(_bold_green "$grade")"
|
||
elif [ "$score" -ge 65 ]; then printf "%s" "$(_bold_yellow "$grade")"
|
||
else printf "%s" "$(_bold_red "$grade")"
|
||
fi
|
||
}
|
||
|
||
safe_count() {
|
||
local result
|
||
result=$("$@" 2>/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<fix_count; i++)); do
|
||
local dim="${fix_dims[$i]}"
|
||
local label="${fix_labels[$i]}"
|
||
local source="${fix_sources[$i]}"
|
||
|
||
if [ "$dim" != "$prev_dim" ]; then
|
||
echo ""
|
||
echo " $(_bold_cyan "$dim:")"
|
||
prev_dim="$dim"
|
||
fi
|
||
|
||
echo " $(_yellow "→") $label"
|
||
echo " $(_dim "$source")"
|
||
done
|
||
|
||
echo ""
|
||
echo " $(_dim "Sources: spec = agentskills.io/specification")"
|
||
echo " $(_dim " official = agentskills.io/skill-creation/*")"
|
||
echo " $(_dim " community = mdskills.ai/docs/skill-best-practices")"
|
||
else
|
||
echo ""
|
||
_pass "Perfect score — no improvements needed"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# OUTPUT MODES
|
||
# ════════════════════════════════════════════════════════════════════
|
||
|
||
print_json() {
|
||
local elapsed=$(( SECONDS - SCAN_START ))
|
||
|
||
# Collect findings into JSON arrays
|
||
local errors_json="[]" warnings_json="[]" info_json="[]"
|
||
|
||
local err_items=""
|
||
while IFS='|' read -r sev sec msg; do
|
||
[ -z "$sev" ] && continue
|
||
msg=$(echo "$msg" | sed 's/"/\\"/g')
|
||
sec=$(echo "$sec" | sed 's/"/\\"/g')
|
||
case "$sev" in
|
||
ERROR) err_items="${err_items}{\"section\":\"$sec\",\"message\":\"$msg\"}," ;;
|
||
esac
|
||
done < "$FINDINGS_FILE"
|
||
|
||
local warn_items=""
|
||
while IFS='|' read -r sev sec msg; do
|
||
[ -z "$sev" ] && continue
|
||
msg=$(echo "$msg" | sed 's/"/\\"/g')
|
||
sec=$(echo "$sec" | sed 's/"/\\"/g')
|
||
case "$sev" in
|
||
WARN) warn_items="${warn_items}{\"section\":\"$sec\",\"message\":\"$msg\"}," ;;
|
||
esac
|
||
done < "$FINDINGS_FILE"
|
||
|
||
local info_items=""
|
||
while IFS='|' read -r sev sec msg; do
|
||
[ -z "$sev" ] && continue
|
||
msg=$(echo "$msg" | sed 's/"/\\"/g')
|
||
sec=$(echo "$sec" | sed 's/"/\\"/g')
|
||
case "$sev" in
|
||
INFO) info_items="${info_items}{\"section\":\"$sec\",\"message\":\"$msg\"}," ;;
|
||
esac
|
||
done < "$FINDINGS_FILE"
|
||
|
||
# Strip trailing commas and wrap
|
||
err_items="${err_items%,}"
|
||
warn_items="${warn_items%,}"
|
||
info_items="${info_items%,}"
|
||
[ -n "$err_items" ] && errors_json="[$err_items]"
|
||
[ -n "$warn_items" ] && warnings_json="[$warn_items]"
|
||
[ -n "$info_items" ] && info_json="[$info_items]"
|
||
|
||
local result="PASS"
|
||
[ "$WARNINGS" -gt 0 ] && result="PASS_WITH_WARNINGS"
|
||
[ "$ERRORS" -gt 0 ] && result="FAIL"
|
||
|
||
cat <<ENDJSON
|
||
{
|
||
"version": "4.0",
|
||
"skill": "$(pwd)",
|
||
"timestamp": "$(date -u '+%Y-%m-%dT%H:%M:%SZ')",
|
||
"duration_seconds": $elapsed,
|
||
"result": "$result",
|
||
"counts": {
|
||
"checks": $CHECKS_RUN,
|
||
"errors": $ERRORS,
|
||
"warnings": $WARNINGS,
|
||
"info": $INFO_COUNT
|
||
},
|
||
"quality_score": {
|
||
"total": $QUALITY_SCORE,
|
||
"grade": "$QUALITY_GRADE",
|
||
"dimensions": {
|
||
"description_quality": { "score": $QUALITY_DESC, "max": 30 },
|
||
"instruction_clarity": { "score": $QUALITY_CLARITY, "max": 25 },
|
||
"spec_compliance": { "score": $QUALITY_SPEC, "max": 20 },
|
||
"progressive_disclosure": { "score": $QUALITY_PROGRESSIVE, "max": 15 },
|
||
"security": { "score": $QUALITY_SECURITY, "max": 10 }
|
||
}
|
||
},
|
||
"token_analysis": {
|
||
"skill_md_tokens": $_S_BODY_TOKENS,
|
||
"body_lines": $_S_BODY_LINES,
|
||
"code_blocks": $_S_CODE_BLOCKS,
|
||
"headings": $_S_HEADINGS,
|
||
"reference_links": $_S_REF_LINKS,
|
||
"reference_files_total": $_S_REFS_TOTAL,
|
||
"reference_files_linked": $_S_REFS_LINKED
|
||
},
|
||
"findings": {
|
||
"errors": $errors_json,
|
||
"warnings": $warnings_json,
|
||
"info": $info_json
|
||
}
|
||
}
|
||
ENDJSON
|
||
}
|
||
|
||
print_markdown() {
|
||
local elapsed=$(( SECONDS - SCAN_START ))
|
||
|
||
local result_icon result_text
|
||
if [ "$ERRORS" -gt 0 ]; then
|
||
result_icon="x" result_text="FAIL — $ERRORS error(s), $WARNINGS warning(s)"
|
||
elif [ "$WARNINGS" -gt 0 ]; then
|
||
result_icon="!" result_text="PASS with $WARNINGS warning(s)"
|
||
else
|
||
result_icon="+" result_text="All checks passed"
|
||
fi
|
||
|
||
cat <<ENDMD
|
||
# Agent Skill Scan Report
|
||
|
||
**Score: $QUALITY_SCORE/100 ($QUALITY_GRADE)** | Result: $result_text | Duration: ${elapsed}s
|
||
|
||
## Score Breakdown
|
||
|
||
| Dimension | Score | Max | % |
|
||
|---|---|---|---|
|
||
| Description Quality | $QUALITY_DESC | 30 | $(( (QUALITY_DESC * 100) / 30 ))% |
|
||
| Instruction Clarity | $QUALITY_CLARITY | 25 | $(( (QUALITY_CLARITY * 100) / 25 ))% |
|
||
| Spec Compliance | $QUALITY_SPEC | 20 | $(( (QUALITY_SPEC * 100) / 20 ))% |
|
||
| Progressive Disclosure | $QUALITY_PROGRESSIVE | 15 | $(( (QUALITY_PROGRESSIVE * 100) / 15 ))% |
|
||
| Security | $QUALITY_SECURITY | 10 | $(( (QUALITY_SECURITY * 100) / 10 ))% |
|
||
| **Total** | **$QUALITY_SCORE** | **100** | **${QUALITY_SCORE}%** |
|
||
|
||
## Summary
|
||
|
||
| Metric | Count |
|
||
|---|---|
|
||
| Sections checked | $CHECKS_RUN |
|
||
| Errors | $ERRORS |
|
||
| Warnings | $WARNINGS |
|
||
| Suggestions | $INFO_COUNT |
|
||
|
||
## Token Analysis
|
||
|
||
| File | Tokens | Lines |
|
||
|---|---|---|
|
||
| SKILL.md | ~$_S_BODY_TOKENS | $_S_BODY_LINES |
|
||
| Reference files | $_S_REFS_TOTAL files | $_S_REFS_LINKED linked |
|
||
ENDMD
|
||
|
||
# Findings
|
||
local total_findings
|
||
total_findings=$(wc -l < "$FINDINGS_FILE" | tr -d ' ')
|
||
|
||
if [ "$total_findings" -gt 0 ]; then
|
||
echo ""
|
||
echo "## Findings"
|
||
echo ""
|
||
|
||
local has_errors has_warnings has_info
|
||
has_errors=$(grep -c "^ERROR|" "$FINDINGS_FILE" 2>/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 "<details>"
|
||
echo "<summary>Click to expand ($total_findings findings)</summary>"
|
||
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 "</details>"
|
||
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 "$@"
|