Skip to content
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.

From plugin
vgv-wingspan
11112 skills10 agents1 hook1 MCP
Install
$ npx -y skills add VeryGoodOpenSource/vgv-wingspan --agent claude-code

Ships with vgv-wingspan. Installing the plugin gets these hooks.

What fires, and when

PreToolUse

  • MatchesRead|Glob|Grep${CLAUDE_PLUGIN_ROOT}/hooks/recommend-plugins.sh
Read hooks/hooks.json

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