Development
Hook
Hooks
What vgv-wingspan runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
$ npx -y skills add VeryGoodOpenSource/vgv-wingspan --agent claude-codeShips with vgv-wingspan. Installing the plugin gets these hooks.
What fires, and when
PreToolUse
- Matches
Read|Glob|Grep${CLAUDE_PLUGIN_ROOT}/hooks/recommend-plugins.sh
In the plugin's words
How vgv-wingspan describes its own hook set.
Wingspan hooks for development workflow automation
Where it lives
- hooks/recommend-plugins.shRunsGitHub
Read the script
#!/bin/bash # PreToolUse hook: recommend companion plugins based on project type. # # Scans JSON files in the recommendations/ directory. Each file describes # a plugin to recommend: how to detect the project type (a file + grep # pattern) and the plugin name to look for in Claude Code settings. # # To add a new recommendation, drop a JSON file in recommendations/ with: # { # "plugin": "plugin-name", # "detect": { "file": "Gemfile", "pattern": "^\\s*gem\\s+.rails." }, # "marketplace": "OrgName/repo-name", (GitHub owner/repo) # "description": "What the plugin provides." # } # # The "detect" field can be a single object or an array of objects (OR logic). # Each object supports either "file" (exact path) or "files" (shell glob for # content search — greps inside every matching file for "pattern"): # "detect": [ # { "file": "pubspec.yaml", "pattern": "." }, # { "files": "docs/plan/*.md", "pattern": "flutter|dart" } # ] # # All matching recommendations are collected and emitted together in a # single message. A per-project temp marker (/tmp/wingspan-recommend-plugins- # <hash>) ensures recommendations are emitted at most once per session. # Without it, the hook would inject the same context on every matched tool # call. INPUT=$(cat) # Skip if we already ran recommendations for this project in this session. PROJECT_HASH=$(echo "$PWD" | shasum | cut -d' ' -f1) MARKER="/tmp/wingspan-recommend-plugins-$PROJECT_HASH" if [[ -f "$MARKER" ]]; then exit 0 fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" RECOMMENDATIONS_DIR="$SCRIPT_DIR/recommendations" if [[ ! -d "$RECOMMENDATIONS_DIR" ]]; then exit 0 fi # Collect settings files to check (local > project > user). SETTINGS_FILES=( ".claude/settings.local.json" ".claude/settings.json" "$HOME/.claude/settings.json" ) is_plugin_installed() { local plugin_name="$1" for settings_file in "${SETTINGS_FILES[@]}"; do if [[ -f "$settings_file" ]] && grep -q "$plugin_name" "$settings_file" 2>/dev/null; then return 0 fi done return 1 } # Evaluate each recommendation file and collect all matches. RECOMMENDATIONS=() for rec_file in "$RECOMMENDATIONS_DIR"/*.json; do [[ -f "$rec_file" ]] || continue plugin=$(jq -r '.plugin' "$rec_file") marketplace=$(jq -r '.marketplace' "$rec_file") description=$(jq -r '.description' "$rec_file") # Project type detection — supports single object or array of objects (OR logic). # Each object can use "file" (exact path) or "files" (shell glob pattern). detect_type=$(jq -r '.detect | type' "$rec_file") if [[ "$detect_type" == "array" ]]; then detect_entries=$(jq -c '.detect[]' "$rec_file") else detect_entries=$(jq -c '.detect' "$rec_file") fi matched=false while IFS= read -r entry; do entry_file=$(echo "$entry" | jq -r '.file // empty') entry_files=$(echo "$entry" | jq -r '.files // empty') entry_pattern=$(echo "$entry" | jq -r '.pattern') if [[ -n "$entry_file" ]]; then # Exact file detection if [[ -f "$entry_file" ]] && grep -qiE "$entry_pattern" "$entry_file" 2>/dev/null; then matched=true break fi elif [[ -n "$entry_files" ]]; then # Content search — grep inside files matching the glob pattern for gf in $entry_files; do if [[ -f "$gf" ]] && grep -qiE "$entry_pattern" "$gf" 2>/dev/null; then matched=true break 2 fi done fi done <<< "$detect_entries" if [[ "$matched" != "true" ]]; then continue fi # Already installed — skip if is_plugin_installed "$plugin"; then continue fi # Collect recommendation RECOMMENDATIONS+=("The '${plugin}' Claude Code plugin is not installed. It provides: ${description} Recommend the user install it by first adding the marketplace with: /plugin marketplace add ${marketplace} — then installing via: /plugin install ${plugin}") done # Emit all recommendations in a single message, then set the marker. if [[ ${#RECOMMENDATIONS[@]} -gt 0 ]]; then touch "$MARKER" newline=$'\n' message="" for rec in "${RECOMMENDATIONS[@]}"; do if [[ -n "$message" ]]; then message="$message${newline}${newline}$rec" else message="$rec" fi done jq -n \ --arg ctx "$message" \ '{ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: $ctx } }' fi - hooks/test_recommend_plugins.shGitHub
Read the script
#!/bin/bash # Tests for recommend-plugins.sh # # Usage: bash hooks/test_recommend_plugins.sh # # Creates a temporary project directory with controlled fixtures and runs # the hook script in isolation. Each test gets a fresh temp directory and # a clean marker state so tests don't interfere with each other. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" HOOK_SCRIPT="$SCRIPT_DIR/recommend-plugins.sh" PASS=0 FAIL=0 # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- setup() { TEST_DIR=$(mktemp -d) RECOMMENDATIONS_DIR="$TEST_DIR/hooks/recommendations" mkdir -p "$RECOMMENDATIONS_DIR" # Copy hook script so SCRIPT_DIR resolves to the temp hooks/ dir. WRAPPER="$TEST_DIR/hooks/recommend-plugins.sh" cp "$HOOK_SCRIPT" "$WRAPPER" chmod +x "$WRAPPER" # Override HOME so the script's $HOME/.claude/settings.json check # reads from the test directory instead of the real user home. ORIGINAL_HOME="$HOME" export HOME="$TEST_DIR" # Ensure no stale marker exists for this test dir. PROJECT_HASH=$(echo "$TEST_DIR" | shasum | cut -d' ' -f1) MARKER="/tmp/wingspan-recommend-plugins-$PROJECT_HASH" rm -f "$MARKER" } teardown() { export HOME="$ORIGINAL_HOME" rm -f "$MARKER" 2>/dev/null || true rm -rf "$TEST_DIR" 2>/dev/null || true } # Run the hook from the test project directory. Captures stdout. run_hook() { echo '{}' | (cd "$TEST_DIR" && bash "$WRAPPER") 2>/dev/null || true } # Add a recommendation JSON file to the test fixtures. add_recommendation() { local name="$1" local content="$2" echo "$content" > "$RECOMMENDATIONS_DIR/$name.json" } # Add a settings file relative to the test dir. add_settings() { local path="$1" local content="$2" mkdir -p "$TEST_DIR/$(dirname "$path")" echo "$content" > "$TEST_DIR/$path" } # Add a project file relative to the test dir. add_project_file() { local path="$1" local content="$2" mkdir -p "$TEST_DIR/$(dirname "$path")" echo "$content" > "$TEST_DIR/$path" } assert_contains() { local output="$1" local expected="$2" local msg="$3" if echo "$output" | grep -q "$expected"; then PASS=$((PASS + 1)) echo " PASS: $msg" else FAIL=$((FAIL + 1)) echo " FAIL: $msg" echo " expected to contain: $expected" echo " got: $output" fi } assert_not_contains() { local output="$1" local unexpected="$2" local msg="$3" if echo "$output" | grep -q "$unexpected"; then FAIL=$((FAIL + 1)) echo " FAIL: $msg" echo " expected NOT to contain: $unexpected" echo " got: $output" else PASS=$((PASS + 1)) echo " PASS: $msg" fi } assert_empty() { local output="$1" local msg="$2" if [[ -z "$output" ]]; then PASS=$((PASS + 1)) echo " PASS: $msg" else FAIL=$((FAIL + 1)) echo " FAIL: $msg" echo " expected empty output, got: $output" fi } assert_file_exists() { local path="$1" local msg="$2" if [[ -f "$path" ]]; then PASS=$((PASS + 1)) echo " PASS: $msg" else FAIL=$((FAIL + 1)) echo " FAIL: $msg" echo " file does not exist: $path" fi } assert_file_not_exists() { local path="$1" local msg="$2" if [[ ! -f "$path" ]]; then PASS=$((PASS + 1)) echo " PASS: $msg" else FAIL=$((FAIL + 1)) echo " FAIL: $msg" echo " file should not exist: $path" fi } # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- test_no_recommendations_dir() { echo "test: exits silently when recommendations dir is missing" setup rm -rf "$RECOMMENDATIONS_DIR" local output output=$(run_hook) assert_empty "$output" "no output when recommendations dir missing" assert_file_not_exists "$MARKER" "no marker written" teardown } test_no_recommendation_files() { echo "test: exits silently when recommendations dir is empty" setup local output output=$(run_hook) assert_empty "$output" "no output when no recommendation files" assert_file_not_exists "$MARKER" "no marker written" teardown } test_file_detection_matches() { echo "test: recommends plugin when 'file' detection matches" setup add_recommendation "test-plugin" '{ "plugin": "test-plugin", "detect": { "file": "pubspec.yaml", "pattern": "flutter" }, "marketplace": "Org/repo", "description": "Test plugin." }' add_project_file "pubspec.yaml" "dependencies: flutter: sdk: flutter" local output output=$(run_hook) assert_contains "$output" "test-plugin" "plugin name in output" assert_contains "$output" "additionalContext" "outputs additionalContext JSON" assert_contains "$output" "Test plugin." "description in output" assert_file_exists "$MARKER" "marker file created" teardown } test_file_detection_no_match_missing_file() { echo "test: no recommendation when detected file does not exist" setup add_recommendation "test-plugin" '{ "plugin": "test-plugin", "detect": { "file": "pubspec.yaml", "pattern": "flutter" }, "marketplace": "Org/repo", "description": "Test plugin." }' local output output=$(run_hook) assert_empty "$output" "no output when file missing" assert_file_not_exists "$MARKER" "no marker written" teardown } test_file_detection_no_match_pattern() { echo "test: no recommendation when file exists but pattern does not match" setup add_recommendation "test-plugin" '{ "plugin": "test-plugin", "detect": { "file": "pubspec.yaml", "pattern": "flutter" }, "marketplace": "Org/repo", "description": "Test plugin." }' add_project_file "pubspec.yaml" "dependencies: react: ^18.0.0" local output output=$(run_hook) assert_empty "$output" "no output when pattern does not match" assert_file_not_exists "$MARKER" "no marker written" teardown } test_files_glob_detection_matches() {
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withvgv-wingspan
🦋 AI-assisted workflows that follow Very Good Ventures best practices and standards. Developed with 💙 by Very Good Ventures 🦄
Get the whole plugin

