Files
getsentry__sentry-for-ai/scripts/build-skill-tree.sh
Daniel Griesser a68d57cfa6 fix(scripts): harden build-skill-tree.sh for bash 3.2 on macOS
Use ${ARRAY[@]+"${ARRAY[@]}"} pattern for all array expansions that
could be empty (category arrays, errors array, function parameters).
Bash 3.2 with set -u treats "${EMPTY[@]}" as an unbound variable error,
while bash 4+ handles it gracefully. The +"..." syntax expands to nothing
when the array is unset/empty, avoiding the crash.

Also fix AGENTS.md claiming CI only runs on skills/** changes — the
validate-skill-tree job runs on all PRs (it's fast enough not to need
path filtering).
2026-03-02 09:21:07 +01:00

382 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# ============================================================
# build-skill-tree.sh — Generate and validate the Sentry skill tree
# ============================================================
# Scans all skills/*/SKILL.md files, regenerates SKILL_TREE.md,
# validates the skill hierarchy, and checks breadcrumb links.
#
# Usage:
# scripts/build-skill-tree.sh # regenerate + validate
# scripts/build-skill-tree.sh --check # validate only (no write)
#
# Exit codes: 0 = pass, 1 = errors found
# Requirements: bash 3.2+, grep, sed, awk, diff, find
set -euo pipefail
# ── Setup ────────────────────────────────────────────────────
CHECK_ONLY=false
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=true
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
SKILL_TREE_FILE="SKILL_TREE.md"
SKILLS_DIR="skills"
# Temp directory for per-skill data (bash 3 compatible, no assoc arrays)
TMPDIR_SKILLS="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_SKILLS"' EXIT
ERRORS=()
error() { ERRORS+=("ERROR: $*"); }
warn() { echo "WARN: $*" >&2; }
# ============================================================
# SECTION 1: Parse frontmatter from a SKILL.md file
# Outputs: key=value lines for known fields
# ============================================================
parse_frontmatter() {
local file="$1"
awk '
BEGIN { in_fm=0; fm_count=0 }
/^---$/ {
fm_count++
if (fm_count == 1) { in_fm=1; next }
if (fm_count == 2) { exit }
}
in_fm && /^[a-zA-Z-]+:/ {
colon = index($0, ":")
key = substr($0, 1, colon - 1)
val = substr($0, colon + 2)
# Remove leading/trailing whitespace from val
gsub(/^[[:space:]]+|[[:space:]]+$/, "", val)
# Normalize key: replace hyphens with underscores
gsub(/-/, "_", key)
print key "=" val
}
' "$file"
}
# Write a field value to a temp file for skill $name
skill_set() {
local name="$1" field="$2" value="$3"
# Sanitize name for filesystem use (replace / and spaces)
local safe_name="${name//[^a-zA-Z0-9_-]/_}"
printf '%s' "$value" > "${TMPDIR_SKILLS}/${safe_name}.${field}"
}
# Read a field value for skill $name (empty string if missing)
skill_get() {
local name="$1" field="$2"
local safe_name="${name//[^a-zA-Z0-9_-]/_}"
local f="${TMPDIR_SKILLS}/${safe_name}.${field}"
[[ -f "$f" ]] && cat "$f" || echo ""
}
# ============================================================
# SECTION 2: Scan all skills
# ============================================================
ALL_SKILLS=()
while IFS= read -r skill_file; do
s_name="" s_desc="" s_cat="" s_parent="" s_role="" s_disable=""
while IFS='=' read -r key val; do
case "$key" in
name) s_name="$val" ;;
description) s_desc="$val" ;;
category) s_cat="$val" ;;
parent) s_parent="$val" ;;
role) s_role="$val" ;;
disable_model_invocation) s_disable="$val" ;;
esac
done < <(parse_frontmatter "$skill_file")
# Fall back to directory name if name field is missing
[[ -z "$s_name" ]] && s_name="$(basename "$(dirname "$skill_file")")"
ALL_SKILLS+=("$s_name")
skill_set "$s_name" "desc" "$s_desc"
skill_set "$s_name" "category" "$s_cat"
skill_set "$s_name" "parent" "$s_parent"
skill_set "$s_name" "role" "$s_role"
skill_set "$s_name" "disable" "$s_disable"
skill_set "$s_name" "file" "$skill_file"
done < <(find "$SKILLS_DIR" -name "SKILL.md" | sort)
TOTAL_SKILLS=${#ALL_SKILLS[@]}
# ============================================================
# SECTION 3: Categorize
# ============================================================
ROUTERS=()
SKILLS_SDK_SETUP=()
SKILLS_WORKFLOW=()
SKILLS_FEATURE_SETUP=()
SKILLS_INTERNAL=()
for name in "${ALL_SKILLS[@]}"; do
role="$(skill_get "$name" role)"
cat="$(skill_get "$name" category)"
if [[ "$role" == "router" ]]; then
ROUTERS+=("$name")
else
case "$cat" in
sdk-setup) SKILLS_SDK_SETUP+=("$name") ;;
workflow) SKILLS_WORKFLOW+=("$name") ;;
feature-setup) SKILLS_FEATURE_SETUP+=("$name") ;;
internal) SKILLS_INTERNAL+=("$name") ;;
esac
fi
done
TOTAL_ROUTERS=${#ROUTERS[@]}
# ============================================================
# SECTION 4: Generate SKILL_TREE.md content
# ============================================================
# Extract a short column value from a description.
# sdk-setup: "Full Sentry SDK setup for X." -> "X"
# others: first sentence
get_column_value() {
local desc="$1"
local category="$2"
case "$category" in
sdk-setup)
echo "$desc" \
| sed 's/Full Sentry SDK setup for //' \
| sed 's/\. .*//' \
| sed 's/\.$//'
;;
*)
echo "$desc" \
| sed 's/\. .*//' \
| sed 's/\.$//'
;;
esac
}
column_header() {
case "$1" in
sdk-setup) echo "Platform" ;;
workflow) echo "Use when" ;;
feature-setup) echo "Feature" ;;
internal) echo "Purpose" ;;
*) echo "Notes" ;;
esac
}
# Build markdown table rows for a list of skills in a category
build_table_rows() {
local category="$1"
shift
local skills=("$@")
for name in ${skills[@]+"${skills[@]}"}; do
local file desc col_val
file="$(skill_get "$name" file)"
desc="$(skill_get "$name" desc)"
col_val="$(get_column_value "$desc" "$category")"
printf "| [\`%s\`](%s) | %s | %s |\n" "$name" "$file" "$file" "$col_val"
done
}
generate_skill_tree() {
local sdk_router="sentry-sdk-setup"
local wf_router="sentry-workflow"
local fs_router="sentry-feature-setup"
cat <<'HEADER'
# Sentry Skill Tree
This file maps the full skill structure for the Sentry-for-AI plugin. Read it to find the right skill for any task, then follow the path to load it.
## Quick Navigation
| If the user wants to... | Start here |
|---|---|
| Set up Sentry in a project | [`sentry-sdk-setup`](skills/sentry-sdk-setup/SKILL.md) |
| Fix issues, review code, debug production | [`sentry-workflow`](skills/sentry-workflow/SKILL.md) |
| Configure a specific Sentry feature | [`sentry-feature-setup`](skills/sentry-feature-setup/SKILL.md) |
HEADER
# SDK Setup
local col_sdk col_wf col_fs col_int
col_sdk="$(column_header sdk-setup)"
printf "\n## SDK Setup ([\`%s\`](%s))\n\n" \
"$sdk_router" "skills/$sdk_router/SKILL.md"
printf "| Skill | Path | %s |\n" "$col_sdk"
printf "|---|---|---|\n"
build_table_rows "sdk-setup" ${SKILLS_SDK_SETUP[@]+"${SKILLS_SDK_SETUP[@]}"}
# Workflow
col_wf="$(column_header workflow)"
printf "\n## Workflow ([\`%s\`](%s))\n\n" \
"$wf_router" "skills/$wf_router/SKILL.md"
printf "| Skill | Path | %s |\n" "$col_wf"
printf "|---|---|---|\n"
build_table_rows "workflow" ${SKILLS_WORKFLOW[@]+"${SKILLS_WORKFLOW[@]}"}
# Feature Setup
col_fs="$(column_header feature-setup)"
printf "\n## Feature Setup ([\`%s\`](%s))\n\n" \
"$fs_router" "skills/$fs_router/SKILL.md"
printf "| Skill | Path | %s |\n" "$col_fs"
printf "|---|---|---|\n"
build_table_rows "feature-setup" ${SKILLS_FEATURE_SETUP[@]+"${SKILLS_FEATURE_SETUP[@]}"}
# Internal (no router)
col_int="$(column_header internal)"
printf "\n## Internal\n\n"
printf "| Skill | Path | %s |\n" "$col_int"
printf "|---|---|---|\n"
build_table_rows "internal" ${SKILLS_INTERNAL[@]+"${SKILLS_INTERNAL[@]}"}
printf "\n"
}
# ============================================================
# SECTION 5: Validate
# ============================================================
KNOWN_CATEGORIES=("sdk-setup" "workflow" "feature-setup" "internal")
validate() {
for name in "${ALL_SKILLS[@]}"; do
local role cat parent disable skill_file
role="$(skill_get "$name" role)"
cat="$(skill_get "$name" category)"
parent="$(skill_get "$name" parent)"
disable="$(skill_get "$name" disable)"
skill_file="$(skill_get "$name" file)"
# ── (a/b/c) Required fields per skill type ───────────────
if [[ "$role" == "router" ]]; then
: # role: router is sufficient
elif [[ "$cat" == "internal" ]]; then
# (b) Internal skills
[[ "$disable" == "true" ]] || \
error "$name: internal skill missing 'disable-model-invocation: true'"
else
# (a) Regular hidden skills
[[ -n "$cat" ]] || \
error "$name: non-router skill missing 'category' field"
[[ -n "$parent" ]] || \
error "$name: non-router skill missing 'parent' field"
[[ "$disable" == "true" ]] || \
error "$name: non-router skill missing 'disable-model-invocation: true'"
fi
# ── (g) Warn on unknown category ─────────────────────────
if [[ -n "$cat" && "$role" != "router" ]]; then
local known=false
for kc in "${KNOWN_CATEGORIES[@]}"; do
[[ "$cat" == "$kc" ]] && known=true && break
done
$known || warn "$name: unknown category '$cat'"
fi
# ── (d) Parent must exist and be a router ────────────────
if [[ -n "$parent" ]]; then
local parent_role
parent_role="$(skill_get "$parent" role)"
if [[ -z "$(skill_get "$parent" file)" ]]; then
error "$name: parent '$parent' does not exist"
elif [[ "$parent_role" != "router" ]]; then
error "$name: parent '$parent' is not a router (role=${parent_role:-none})"
fi
fi
# ── (e) Skill appears in its router's SKILL.md ───────────
if [[ -n "$parent" ]]; then
local router_file
router_file="$(skill_get "$parent" file)"
if [[ -n "$router_file" && -f "$router_file" ]]; then
if ! grep -q "$name" "$router_file" 2>/dev/null; then
error "$name: not listed in router '$parent' ($router_file)"
fi
fi
fi
# ── (f) Breadcrumb links resolve ─────────────────────────
local skill_dir
skill_dir="$(dirname "$skill_file")"
while IFS= read -r breadcrumb_line; do
# Extract only markdown link paths ending in .md: ](path.md)
# Pattern ](path) where path ends with .md (skip http links)
while IFS= read -r link_path; do
[[ "$link_path" =~ ^https?:// ]] && continue
local resolved="$skill_dir/$link_path"
if [[ ! -f "$resolved" ]]; then
error "$name: broken breadcrumb link '$link_path' (resolved: $resolved)"
fi
done < <(echo "$breadcrumb_line" | grep -oE '\]\([^)]+\.md\)' | sed 's/^](\(.*\))$/\1/')
done < <(grep '^> ' "$skill_file" 2>/dev/null || true)
done
}
# ============================================================
# SECTION 6: Run
# ============================================================
echo "Scanning ${TOTAL_SKILLS} skills in ${SKILLS_DIR}/..."
GENERATED="$(generate_skill_tree)"
validate
# ── Stale check / write ──────────────────────────────────────
if [[ -f "$SKILL_TREE_FILE" ]]; then
EXISTING="$(cat "$SKILL_TREE_FILE")"
if [[ "$GENERATED" != "$EXISTING" ]]; then
echo ""
echo "SKILL_TREE.md diff (existing → generated):"
diff <(echo "$EXISTING") <(echo "$GENERATED") || true
echo ""
if $CHECK_ONLY; then
error "SKILL_TREE.md is stale. Run scripts/build-skill-tree.sh to regenerate."
else
echo "SKILL_TREE.md is stale — regenerating..."
printf '%s\n' "$GENERATED" > "$SKILL_TREE_FILE"
echo "SKILL_TREE.md updated."
fi
else
echo "SKILL_TREE.md is up to date."
fi
else
if $CHECK_ONLY; then
error "SKILL_TREE.md does not exist. Run scripts/build-skill-tree.sh to generate."
else
printf '%s\n' "$GENERATED" > "$SKILL_TREE_FILE"
echo "SKILL_TREE.md created."
fi
fi
# ── Summary ──────────────────────────────────────────────────
echo ""
echo "Summary: ${TOTAL_SKILLS} skills scanned, ${TOTAL_ROUTERS} routers, ${#ERRORS[@]} errors"
if [[ ${#ERRORS[@]} -gt 0 ]]; then
echo ""
echo "Errors:"
for e in ${ERRORS[@]+"${ERRORS[@]}"}; do
echo " $e"
done
exit 1
fi
echo "All checks passed."
exit 0