#!/usr/bin/env bash # Sync skill metadata to AGENTS.md Auto-invoke sections # Usage: ./sync.sh [--dry-run] [--scope ] set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(dirname "$(dirname "$(dirname "$SCRIPT_DIR")")")" SKILLS_DIR="$REPO_ROOT/skills" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # Options DRY_RUN=false FILTER_SCOPE="" # Parse arguments while [[ $# -gt 0 ]]; do case $1 in --dry-run) DRY_RUN=true shift ;; --scope) FILTER_SCOPE="$2" shift 2 ;; --help|-h) echo "Usage: $0 [--dry-run] [--scope ]" echo "" echo "Options:" echo " --dry-run Show what would change without modifying files" echo " --scope Only sync specific scope (root, ui, api, sdk, mcp_server)" exit 0 ;; *) echo -e "${RED}Unknown option: $1${NC}" exit 1 ;; esac done # Map scope to AGENTS.md path get_agents_path() { local scope="$1" case "$scope" in root) echo "$REPO_ROOT/AGENTS.md" ;; ui) echo "$REPO_ROOT/ui/AGENTS.md" ;; api) echo "$REPO_ROOT/api/AGENTS.md" ;; sdk) echo "$REPO_ROOT/prowler/AGENTS.md" ;; mcp_server) echo "$REPO_ROOT/mcp_server/AGENTS.md" ;; *) echo "" ;; esac } # Extract YAML frontmatter field using awk extract_field() { local file="$1" local field="$2" awk -v field="$field" ' /^---$/ { in_frontmatter = !in_frontmatter; next } in_frontmatter && $1 == field":" { # Handle single line value sub(/^[^:]+:[[:space:]]*/, "") if ($0 != "" && $0 != ">") { gsub(/^["'\'']|["'\'']$/, "") # Remove quotes print exit } # Handle multi-line value getline while (/^[[:space:]]/ && !/^---$/) { sub(/^[[:space:]]+/, "") printf "%s ", $0 if (!getline) break } print "" exit } ' "$file" | sed 's/[[:space:]]*$//' } # Extract nested metadata field # # Supports either: # auto_invoke: "Single Action" # or: # auto_invoke: # - "Action A" # - "Action B" # # For list values, this returns a pipe-delimited string: "Action A|Action B" extract_metadata() { local file="$1" local field="$2" awk -v field="$field" ' function trim(s) { sub(/^[[:space:]]+/, "", s) sub(/[[:space:]]+$/, "", s) return s } /^---$/ { in_frontmatter = !in_frontmatter; next } in_frontmatter && /^metadata:/ { in_metadata = 1; next } in_frontmatter && in_metadata && /^[a-z]/ && !/^[[:space:]]/ { in_metadata = 0 } in_frontmatter && in_metadata && $1 == field":" { # Remove "field:" prefix sub(/^[^:]+:[[:space:]]*/, "") # Single-line scalar: auto_invoke: "Action" if ($0 != "") { v = $0 gsub(/^["'\'']|["'\'']$/, "", v) gsub(/^\[|\]$/, "", v) # legacy: allow inline [a, b] print trim(v) exit } # Multi-line list: # auto_invoke: # - "Action A" # - "Action B" out = "" while (getline) { # Stop when leaving metadata block if (!in_frontmatter) break if (!in_metadata) break if ($0 ~ /^[a-z]/ && $0 !~ /^[[:space:]]/) break # On multi-line list, only accept "- item" lines. Anything else ends the list. line = $0 # Stop at frontmatter delimiter (getline bypasses pattern matching) if (line ~ /^---$/) break if (line ~ /^[[:space:]]*-[[:space:]]*/) { sub(/^[[:space:]]*-[[:space:]]*/, "", line) line = trim(line) gsub(/^["'\'']|["'\'']$/, "", line) if (line != "") { if (out == "") out = line else out = out "|" line } } else { break } } if (out != "") print out exit } ' "$file" } echo -e "${BLUE}Skill Sync - Updating AGENTS.md Auto-invoke sections${NC}" echo "========================================================" echo "" # Collect skills by scope using temp files (Bash 3 compatible) SCOPE_TMPDIR=$(mktemp -d) trap 'rm -rf "$SCOPE_TMPDIR"' EXIT # Deterministic iteration order (stable diffs) # Note: macOS ships BSD find; avoid GNU-only flags. while IFS= read -r skill_file; do [ -f "$skill_file" ] || continue skill_name=$(extract_field "$skill_file" "name") scope_raw=$(extract_metadata "$skill_file" "scope") auto_invoke_raw=$(extract_metadata "$skill_file" "auto_invoke") # extract_metadata() returns: # - single action: "Action" # - multiple actions: "Action A|Action B" (pipe-delimited) # We use ';;' as separator to avoid conflicts with '|' used between entries. auto_invoke=$(echo "$auto_invoke_raw" | sed 's/|/;;/g') # Skip if no scope or auto_invoke defined [ -z "$scope_raw" ] || [ -z "$auto_invoke" ] && continue # Parse scope (can be comma-separated or space-separated) # Bash 3 compatible: use tr + read instead of read -ra with <<< echo "$scope_raw" | tr ', ' '\n' | while read -r scope; do scope=$(echo "$scope" | tr -d '[:space:]') [ -z "$scope" ] && continue # Filter by scope if specified [ -n "$FILTER_SCOPE" ] && [ "$scope" != "$FILTER_SCOPE" ] && continue # Append to scope's skill file echo "$skill_name:$auto_invoke" >> "$SCOPE_TMPDIR/$scope" done done < <(find "$SKILLS_DIR" -mindepth 2 -maxdepth 2 -name SKILL.md -print | sort) # Generate Auto-invoke section for each scope # Deterministic scope order (stable diffs) for scope_file in "$SCOPE_TMPDIR"/*; do [ -f "$scope_file" ] || continue scope=$(basename "$scope_file") agents_path=$(get_agents_path "$scope") if [ -z "$agents_path" ] || [ ! -f "$agents_path" ]; then echo -e "${YELLOW}Warning: No AGENTS.md found for scope '$scope'${NC}" continue fi echo -e "${BLUE}Processing: $scope -> $(basename "$(dirname "$agents_path")")/AGENTS.md${NC}" # Build the Auto-invoke table auto_invoke_section="### Auto-invoke Skills When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Action | Skill | |--------|-------|" # Expand into sortable rows: "actionskill" rows_file=$(mktemp) while IFS= read -r entry; do [ -z "$entry" ] && continue skill_name="${entry%%:*}" actions_raw="${entry#*:}" # Restore '|' from ';;' and split actions actions_raw=$(echo "$actions_raw" | sed 's/;;/|/g') echo "$actions_raw" | tr '|' '\n' | while read -r action; do action=$(echo "$action" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') [ -z "$action" ] && continue printf "%s\t%s\n" "$action" "$skill_name" >> "$rows_file" done done < "$scope_file" # Deterministic row order: Action then Skill while IFS=$'\t' read -r action skill_name; do [ -z "$action" ] && continue auto_invoke_section="$auto_invoke_section | $action | \`$skill_name\` |" done < <(LC_ALL=C sort -t $'\t' -k1,1 -k2,2 "$rows_file") rm -f "$rows_file" if $DRY_RUN; then echo -e "${YELLOW}[DRY RUN] Would update $agents_path with:${NC}" echo "$auto_invoke_section" echo "" else # Write new section to temp file (avoids awk multi-line string issues on macOS) section_file=$(mktemp) echo "$auto_invoke_section" > "$section_file" # Check if Auto-invoke section exists if grep -q "### Auto-invoke Skills" "$agents_path"; then # Replace existing section (up to next --- or ## heading) awk ' /^### Auto-invoke Skills/ { while ((getline line < "'"$section_file"'") > 0) print line close("'"$section_file"'") skip = 1 next } skip && /^(---|## )/ { skip = 0 print "" } !skip { print } ' "$agents_path" > "$agents_path.tmp" mv "$agents_path.tmp" "$agents_path" echo -e "${GREEN} ✓ Updated Auto-invoke section${NC}" else # Insert after Skills Reference blockquote awk ' /^>.*SKILL\.md\)$/ && !inserted { print getline if (/^$/) { print "" while ((getline line < "'"$section_file"'") > 0) print line close("'"$section_file"'") print "" inserted = 1 next } } { print } ' "$agents_path" > "$agents_path.tmp" mv "$agents_path.tmp" "$agents_path" echo -e "${GREEN} ✓ Inserted Auto-invoke section${NC}" fi rm -f "$section_file" fi done echo "" echo -e "${GREEN}Done!${NC}" # Show skills without metadata echo "" echo -e "${BLUE}Skills missing sync metadata:${NC}" missing=0 while IFS= read -r skill_file; do [ -f "$skill_file" ] || continue skill_name=$(extract_field "$skill_file" "name") scope_raw=$(extract_metadata "$skill_file" "scope") auto_invoke_raw=$(extract_metadata "$skill_file" "auto_invoke") auto_invoke=$(echo "$auto_invoke_raw" | sed 's/|/;;/g') if [ -z "$scope_raw" ] || [ -z "$auto_invoke" ]; then echo -e " ${YELLOW}$skill_name${NC} - missing: ${scope_raw:+}${scope_raw:-scope} ${auto_invoke:+}${auto_invoke:-auto_invoke}" missing=$((missing + 1)) fi done < <(find "$SKILLS_DIR" -mindepth 2 -maxdepth 2 -name SKILL.md -print | sort) if [ $missing -eq 0 ]; then echo -e " ${GREEN}All skills have sync metadata${NC}" fi