Skip to content
Development
Hook

Hooks

What dotnet-claude-kit runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
dotnet-claude-kit
76147 skills10 agents2 hooks1 MCP
Install
> /plugin marketplace add codewithmukesh/dotnet-claude-kit
> /plugin install dotnet-claude-kit@dotnet-claude-kit

Ships with dotnet-claude-kit. Installing the plugin gets these hooks.

What fires, and when

PreToolUse

  • MatchesBashbash "${CLAUDE_PLUGIN_ROOT}/hooks/pre-bash-guard.sh"

PostToolUse

  • MatchesEdit|Writebash "${CLAUDE_PLUGIN_ROOT}/hooks/post-edit-format.sh"bash "${CLAUDE_PLUGIN_ROOT}/hooks/post-scaffold-restore.sh"
Read hooks/hooks.json

Where it lives

  • hooks/post-edit-format.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Post-edit hook: auto-format changed .cs files
    # Runs dotnet format on specific files after Claude edits them.
    #
    # Usage:
    #   Called automatically by Claude Code PostToolUse hook after Edit/Write on .cs files.
    #   Accepts file path via:
    #     1. First argument ($1)
    #     2. CLAUDE_EDITED_FILE env var
    #     3. PostToolUse stdin JSON ({"tool_input":{"file_path":"..."}})
    
    set -euo pipefail
    
    FILE="${1:-${CLAUDE_EDITED_FILE:-}}"
    
    # Fallback: parse file_path from PostToolUse stdin JSON. Prefer jq (handles JSON
    # escapes like \\ in Windows paths correctly); fall back to a sed extraction.
    if [[ -z "$FILE" ]] && [[ ! -t 0 ]]; then
        STDIN=$(cat)
        if command -v jq >/dev/null 2>&1; then
            FILE=$(printf '%s' "$STDIN" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || true
        fi
        if [[ -z "$FILE" ]]; then
            FILE=$(printf '%s' "$STDIN" | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) || true
            # sed sees raw JSON escapes — collapse \\ to \ so the path is usable
            FILE="${FILE//\\\\/\\}"
        fi
    fi
    
    if [[ -z "$FILE" ]]; then
        exit 0
    fi
    
    # Normalize Windows backslash paths (C:\Users\... → C:/Users/...) so the
    # directory walk below works under Git Bash
    FILE="${FILE//\\//}"
    
    # Only format C# files
    if [[ "$FILE" != *.cs ]]; then
        exit 0
    fi
    
    # Skip if file doesn't exist (deleted)
    if [[ ! -f "$FILE" ]]; then
        exit 0
    fi
    
    # Find the nearest .csproj or .sln to scope the format
    DIR=$(dirname "$FILE")
    PROJECT=""
    while [[ "$DIR" != "/" && "$DIR" != "." ]]; do
        # Check for .csproj first (more specific)
        CSPROJ=$(find "$DIR" -maxdepth 1 -name "*.csproj" -print -quit 2>/dev/null || true)
        if [[ -n "$CSPROJ" ]]; then
            PROJECT="$CSPROJ"
            break
        fi
        # Check for .sln
        SLN=$(find "$DIR" -maxdepth 1 -name "*.sln" -print -quit 2>/dev/null || true)
        if [[ -n "$SLN" ]]; then
            PROJECT="$SLN"
            break
        fi
        # Fixed-point guard: on Windows drive roots dirname "C:" returns "C:",
        # which never equals "/" or "." — without this break the walk loops forever
        PARENT=$(dirname "$DIR")
        if [[ "$PARENT" == "$DIR" ]]; then
            break
        fi
        DIR="$PARENT"
    done
    
    if [[ -n "$PROJECT" ]]; then
        dotnet format "$PROJECT" --include "$FILE" --no-restore 2>/dev/null || true
    else
        echo "No .csproj or .sln found for $FILE, skipping format"
    fi
    
  • hooks/post-scaffold-restore.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Post-scaffold hook: restore NuGet packages after .csproj changes
    # Triggered when a .csproj file is created or modified.
    #
    # Usage:
    #   Called automatically by Claude Code PostToolUse hook after Edit/Write.
    #   Accepts file path via:
    #     1. First argument ($1)
    #     2. CLAUDE_EDITED_FILE env var
    #     3. PostToolUse stdin JSON ({"tool_input":{"file_path":"..."}})
    #   When called with no input (manual use), restore runs unconditionally.
    
    set -euo pipefail
    
    FILE="${1:-${CLAUDE_EDITED_FILE:-}}"
    
    # Fallback: parse file_path from PostToolUse stdin JSON. Prefer jq (handles JSON
    # escapes like \\ in Windows paths correctly); fall back to a sed extraction.
    if [[ -z "$FILE" ]] && [[ ! -t 0 ]]; then
        STDIN=$(cat)
        if command -v jq >/dev/null 2>&1; then
            FILE=$(printf '%s' "$STDIN" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || true
        fi
        if [[ -z "$FILE" ]]; then
            FILE=$(printf '%s' "$STDIN" | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) || true
            FILE="${FILE//\\\\/\\}"
        fi
    fi
    
    # If we have a file path, only restore for .csproj files
    if [[ -n "$FILE" ]]; then
        if [[ "$FILE" != *.csproj ]]; then
            exit 0
        fi
    fi
    
    # Run restore
    echo "Project file changed. Running dotnet restore..."
    
    if dotnet restore --verbosity quiet 2>/dev/null; then
        echo "Restore completed."
    else
        echo "Warning: dotnet restore failed. You may need to restore manually."
    fi
    
  • hooks/post-test-analyze.shGitHub
    Read the script
    #!/usr/bin/env bash
    # Post-test hook: analyze test results and output actionable summary
    # Parses dotnet test output for failures and provides a structured report.
    #
    # Usage: Pipe dotnet test output or pass log file as argument.
    # Called automatically after test runs to summarize results.
    
    set -euo pipefail
    
    LOG_FILE="${1:-}"
    TEST_OUTPUT=""
    
    if [[ -n "$LOG_FILE" && -f "$LOG_FILE" ]]; then
        TEST_OUTPUT=$(cat "$LOG_FILE")
    else
        # Read from stdin if available
        if [[ ! -t 0 ]]; then
            TEST_OUTPUT=$(cat)
        else
            echo "Usage: dotnet test 2>&1 | bash hooks/post-test-analyze.sh"
            echo "   or: bash hooks/post-test-analyze.sh <test-output.log>"
            exit 0
        fi
    fi
    
    # Count results by summing the numeric Passed/Failed/Skipped values from each test
    # project's summary line, e.g.:
    #   Passed!  - Failed:     0, Passed:    86, Skipped:     0, Total:    86, Duration: ...
    # The previous approach counted occurrences of the words "Passed!"/"Failed!", which only
    # counts summary lines (≈ test projects), not tests — and "grep -c ... || echo 0"
    # double-appended a value under this script's 'set -euo pipefail'.
    sum_metric() {
        printf '%s\n' "$TEST_OUTPUT" \
            | { grep -oE "$1:[[:space:]]*[0-9]+" || true; } \
            | { grep -oE '[0-9]+' || true; } \
            | awk '{ s += $1 } END { print s + 0 }'
    }
    PASSED=$(sum_metric Passed)
    FAILED=$(sum_metric Failed)
    SKIPPED=$(sum_metric Skipped)
    
    # Extract failure details (printf, not echo — output may start with '-')
    FAILURES=$(printf '%s\n' "$TEST_OUTPUT" | grep -A 5 'Failed ' 2>/dev/null || true)
    
    echo ""
    echo "═══════════════════════════════════"
    echo "  Test Results Summary"
    echo "═══════════════════════════════════"
    echo ""
    
    if [[ "$FAILED" -gt 0 ]]; then
        echo "  🔴 FAILED: $FAILED"
        echo "  ✅ Passed: $PASSED"
        echo "  ⏭️  Skipped: $SKIPPED"
        echo ""
        echo "  Failed Tests:"
        echo "  ─────────────"
        echo "$FAILURES" | head -50
        echo ""
        echo "  Next Steps:"
        echo "  1. Fix the failing tests above"
        echo "  2. Run 'dotnet test' to verify fixes"
        echo "  3. Check test output for root cause details"
    else
        echo "  ✅ All $PASSED test(s) passed"
        if [[ "$SKIPPED" -gt 0 ]]; then
            echo "  ⏭️  $SKIPPED test(s) skipped"
        fi
    fi
    
    echo ""
    echo "═══════════════════════════════════"
    
  • hooks/pre-bash-guard.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Pre-Bash Guard — Block destructive operations
    # Inspects the Bash tool command before execution. Reads CLAUDE_TOOL_INPUT when set,
    # otherwise the PreToolUse payload on stdin — current Claude Code passes hook data on
    # stdin as JSON, not via CLAUDE_TOOL_INPUT, so without the stdin path the guard would
    # see an empty command and never block. The command field is parsed with jq so escaped
    # quotes inside the command don't truncate it; when jq is unavailable, a sed extraction
    # pulls the command field instead of matching against the whole payload (grepping the
    # raw JSON blocked on matches in *other* fields, e.g. a description mentioning
    # "git reset --hard"). Only if both extractions fail does the raw payload get used,
    # which over-blocks rather than under-blocks — the safe direction here.
    # Exit 2 = block the command, exit 0 = allow. Block reasons go to stderr — on exit 2
    # Claude Code feeds stderr (not stdout) back to Claude.
    
    COMMAND="${CLAUDE_TOOL_INPUT:-}"
    
    if [[ -z "$COMMAND" ]] && [[ ! -t 0 ]]; then
      STDIN=$(cat)
      if command -v jq >/dev/null 2>&1; then
        COMMAND=$(printf '%s' "$STDIN" | jq -r '.tool_input.command // empty' 2>/dev/null) || true
      fi
      if [[ -z "$COMMAND" ]]; then
        # No jq: extract the "command" value with sed. Stops at the first unescaped
        # quote, so escaped quotes truncate the tail — acceptable, since the guard
        # patterns match on prefixes and truncation only risks under-matching the
        # tail of unusual commands, not blocking on unrelated payload fields.
        COMMAND=$(printf '%s' "$STDIN" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(\(\\.\|[^"\\]\)*\)".*/\1/p' | head -1) || true
      fi
      [[ -z "$COMMAND" ]] && COMMAND="$STDIN"
    fi
    
    # ── Destructive git operations ──────────────────────────────────────
    
    if echo "$COMMAND" | grep -qE 'git\s+push\s+.*--force|git\s+push\s+-f\b'; then
      echo "BLOCKED: Force push detected. Use regular push or discuss with the user first." >&2
      exit 2
    fi
    
    if echo "$COMMAND" | grep -qE 'git\s+reset\s+--hard'; then
      echo "BLOCKED: git reset --hard will discard all uncommitted changes. Discuss with the user first." >&2
      exit 2
    fi
    
    if echo "$COMMAND" | grep -qE 'git\s+clean\s+-[a-zA-Z]*f'; then
      echo "BLOCKED: git clean -f will permanently delete untracked files. Discuss with the user first." >&2
      exit 2
    fi
    
    if echo "$COMMAND" | grep -qE 'git\s+checkout\s+\.'; then
      echo "BLOCKED: git checkout . will discard all unstaged changes. Discuss with the user first." >&2
      exit 2
    fi
    
    # ── Dangerous file operations ───────────────────────────────────────
    
    if echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]*r[a-zA-Z]*f|rm\s+-[a-zA-Z]*f[a-zA-Z]*r'; then
      # Allow recursive-force rm on safe targets (node_modules, bin, obj, TestResults,
      # .vs, /tmp). Matches any flag spelling the detector matches (-rf, -fr, -rfv, ...)
      # and allows ./-prefixed or relative-path-prefixed targets (e.g. ./bin,
      # src/MyApp/obj). Absolute paths stay blocked (so /bin is NOT allowed) except /tmp.
      if echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]+\s+(\./)?([^ /][^ ]*/)?(node_modules|bin|obj|TestResults|\.vs)/?(\s|$)|rm\s+-[a-zA-Z]+\s+/tmp(/[^ ]*)?(\s|$)'; then
        : # safe, fall through to exit 0
      else
        echo "BLOCKED: rm -rf detected in a project directory. Verify the target path is intentional." >&2
        exit 2
      fi
    fi
    
    # ── dotnet run without launch settings check ────────────────────────
    
    if echo "$COMMAND" | grep -qE 'dotnet\s+run\b'; then
      echo "WARNING: dotnet run detected. Ensure launchSettings.json exists and the correct profile is selected."
      # Allow but warn — exit 0 so it proceeds
    fi
    
    exit 0
    
  • hooks/pre-build-validate.shGitHub
    Read the script
    #!/usr/bin/env bash
    # Pre-build hook: validate project structure matches expected architecture
    # Checks that expected project folders exist and naming conventions are followed.
    #
    # Usage: Run before dotnet build to catch structural issues early.
    # Expects to be run from the solution root directory.
    
    set -euo pipefail
    
    SOLUTION_DIR="${1:-.}"
    ERRORS=0
    WARNINGS=0
    
    echo "Validating project structure..."
    
    # Check for solution file
    SLN_COUNT=$(find "$SOLUTION_DIR" -maxdepth 1 \( -name "*.sln" -o -name "*.slnx" \) 2>/dev/null | wc -l)
    if [[ "$SLN_COUNT" -eq 0 ]]; then
        echo "⚠️  No .sln or .slnx file found in $SOLUTION_DIR"
        WARNINGS=$((WARNINGS + 1))
    fi
    
    # Check for Directory.Build.props (recommended for multi-project solutions)
    CSPROJ_COUNT=$(find "$SOLUTION_DIR" -name "*.csproj" 2>/dev/null | wc -l)
    if [[ "$CSPROJ_COUNT" -gt 2 ]]; then
        if [[ ! -f "$SOLUTION_DIR/Directory.Build.props" ]]; then
            echo "⚠️  Multi-project solution without Directory.Build.props — consider centralizing common settings"
            WARNINGS=$((WARNINGS + 1))
        fi
    fi
    
    # Check for global.json (recommended for SDK version pinning)
    if [[ ! -f "$SOLUTION_DIR/global.json" ]]; then
        echo "⚠️  No global.json found — consider pinning the SDK version"
        WARNINGS=$((WARNINGS + 1))
    fi
    
    # Check for .editorconfig (recommended for code style consistency)
    if [[ ! -f "$SOLUTION_DIR/.editorconfig" ]]; then
        echo "⚠️  No .editorconfig found — consider adding for consistent code style"
        WARNINGS=$((WARNINGS + 1))
    fi
    
    # Check that test projects exist
    TEST_PROJECTS=$(find "$SOLUTION_DIR" -name "*.Tests.csproj" -o -name "*.Test.csproj" -o -name "*Tests.csproj" 2>/dev/null | wc -l)
    if [[ "$TEST_PROJECTS" -eq 0 && "$CSPROJ_COUNT" -gt 1 ]]; then
        echo "⚠️  No test projects found — consider adding tests"
        WARNINGS=$((WARNINGS + 1))
    fi
    
    # Check for mixed target frameworks. Parse both <TargetFramework> and
    # <TargetFrameworks> (the old check only matched the singular tag), split
    # multi-targeting on ';', drop $(...) MSBuild expansions and platform suffixes
    # (e.g. -android, -ios, -windows...), and compare only the base netX.Y so legitimate
    # multi-targeting isn't mis-reported as "mixed". The previous '| wc -l || echo 0' also
    # corrupted the count under 'set -euo pipefail' when grep matched nothing.
    if [[ "$CSPROJ_COUNT" -gt 0 ]]; then
        BASE_TFMS=$(
            find "$SOLUTION_DIR" -type d \( -name bin -o -name obj \) -prune -o \
                -name '*.csproj' -type f \
                -exec grep -hoE '<TargetFrameworks?>[^<]+</TargetFrameworks?>' {} + 2>/dev/null \
                | sed -E 's/<[^>]+>//g' \
                | tr ';' '\n' \
                | sed -E 's/\$\([^)]*\)//g; s/-[A-Za-z0-9.]+$//; s/[[:space:]]//g' \
                | grep -E '^net[0-9]' \
                | sort -u || true
        )
        TFM_COUNT=$(printf '%s\n' "$BASE_TFMS" | grep -c . || true)
        if [[ "${TFM_COUNT:-0}" -gt 1 ]]; then
            echo "⚠️  Mixed target frameworks detected ($(printf '%s' "$BASE_TFMS" | paste -sd, -)) — consider aligning all projects"
            WARNINGS=$((WARNINGS + 1))
        fi
    fi
    
    # Summary
    echo ""
    if [[ $ERRORS -gt 0 ]]; then
        echo "🔴 $ERRORS error(s) found — fix before building"
        exit 1
    elif [[ $WARNINGS -gt 0 ]]; then
        echo "⚠️  $WARNINGS warning(s) — consider addressing these"
        exit 0
    else
        echo "✓ Project structure looks good"
        exit 0
    fi
    
  • hooks/pre-commit-antipattern.shGitHub
    Read the script
    #!/usr/bin/env bash
    # Pre-commit hook: detect anti-patterns in the lines a commit adds.
    #
    # This is the no-build tier of the kit's three-tier detection model (ADR-006).
    # A git hook runs outside Claude Code, so the Roslyn MCP server is unreachable
    # and a full compile is too slow to sit in front of every commit. So this scans
    # text — but it scans it carefully: comments and string literals are stripped,
    # only added lines are tested, and rules that need a semantic model to be
    # certain stay silent rather than guess. Rule IDs, severities, and source-kind
    # exemptions match the Roslyn detectors exactly.
    #
    # For exhaustive, semantically precise analysis, use the Roslyn MCP
    # detect_antipatterns tool, or /verify, which runs it.
    #
    # Suppress a line with a trailing "// cwm:ignore" or "// cwm:ignore AP004".
    # Set CWM_ANTIPATTERN_WARN_ONLY=1 to report findings without blocking.
    #
    # Exit codes:
    #   0 — No issues found (or findings downgraded by CWM_ANTIPATTERN_WARN_ONLY)
    #   1 — Issues found, commit blocked
    #   2 — The hook itself could not run
    
    set -euo pipefail
    
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    SCANNER="$SCRIPT_DIR/lib/antipattern-scan.awk"
    
    if [[ ! -f "$SCANNER" ]]; then
        echo "pre-commit-antipattern: scanner not found at $SCANNER" >&2
        exit 2
    fi
    
    # Classification uses repo-root-relative paths (an absolute path can contain a
    # "tests" or "obj" segment anywhere above the root and misclassify everything
    # below it), so run from the root regardless of where git invoked the hook.
    REPO_ROOT="$(git rev-parse --show-toplevel)"
    cd "$REPO_ROOT"
    
    # Read one path per line so paths containing spaces stay intact (a plain
    # "for FILE in $STAGED_FILES" word-splits them). Uses a while-read loop rather
    # than mapfile for bash 3.2 (macOS) compatibility.
    STAGED_FILES=()
    while IFS= read -r FILE; do
        [[ -n "$FILE" ]] && STAGED_FILES+=("$FILE")
    done < <(git diff --cached --name-only --diff-filter=ACM | grep '\.cs$' || true)
    
    if [[ ${#STAGED_FILES[@]} -eq 0 ]]; then
        exit 0
    fi
    
    TMP_DIR="$(mktemp -d)"
    trap 'rm -rf "$TMP_DIR"' EXIT
    
    # Mirrors SourceClassifier.Classify: generated wins over migration, which wins
    # over test. Echoes one of generated|migration|test|production.
    classify() {
        local path="$1"
        local name="${path##*/}"
        local stem="${name%.cs}"
    
        case "/$path" in
            */obj/*|*/bin/*) echo generated; return ;;
        esac
        case "$name" in
            *.g.cs|*.g.i.cs|*.Designer.cs|*.generated.cs|*.AssemblyInfo.cs)
                echo generated; return ;;
        esac
        if git show ":$path" 2>/dev/null | head -5 | grep -qi '<auto-generated'; then
            echo generated; return
        fi
    
        case "/$path" in
            */Migrations/*) echo migration; return ;;
        esac
        case "$stem" in
            *ModelSnapshot) echo migration; return ;;
        esac
    
        case "/$path" in
            */tests/*|*/test/*|*/Tests/*|*/Test/*) echo test; return ;;
        esac
        # Case-sensitive so production names like "Contest.cs" or "Manifest.cs" are
        # not mistaken for test files.
        case "$stem" in
            *Tests|*Test|*Spec|*Specs|*TestFixture|*TestBase) echo test; return ;;
        esac
    
        echo production
    }
    
    # Line numbers this commit adds, derived from the staged diff. A pre-existing
    # violation on a line the commit does not touch is not this commit's problem —
    # without this, one legacy DateTime.Now blocks every future edit to the file.
    added_lines() {
        git diff --cached -U0 -- "$1" | awk '
            /^\+\+\+/ { next }
            /^@@/ {
                match($0, /\+[0-9]+/)
                n = substr($0, RSTART + 1, RLENGTH - 1) + 0
                next
            }
            /^\+/ { print n; n++ }
        '
    }
    
    ERRORS=0
    WARNINGS=0
    FINDINGS="$TMP_DIR/findings"
    : > "$FINDINGS"
    
    for FILE in "${STAGED_FILES[@]}"; do
        KIND="$(classify "$FILE")"
        [[ "$KIND" == "generated" ]] && continue
    
        added_lines "$FILE" > "$TMP_DIR/lines"
        [[ -s "$TMP_DIR/lines" ]] || continue
    
        git show ":$FILE" > "$TMP_DIR/source" 2>/dev/null || continue
    
        # Seeders are one-shot data setup with no test surface an injected clock
        # would improve — the Roslyn detector downgrades them, so we skip them.
        SEEDER=0
        case "$(echo "${FILE##*/}" | tr '[:upper:]' '[:lower:]')" in
            *seed*) SEEDER=1 ;;
        esac
    
        while IFS='|' read -r RULE SEVERITY LINE MESSAGE SUGGESTION SNIPPET; do
            [[ -n "$RULE" ]] || continue
    
            if [[ "$SEVERITY" == "error" ]]; then
                ERRORS=$((ERRORS + 1))
                MARK="error  "
            else
                WARNINGS=$((WARNINGS + 1))
                MARK="warning"
            fi
    
            {
                printf '%s  %s:%s  [%s] %s\n' "$MARK" "$FILE" "$LINE" "$RULE" "$MESSAGE"
                printf '           %s\n' "$SNIPPET"
                printf '           → %s\n' "$SUGGESTION"
            } >> "$FINDINGS"
        done < <(awk -v kind="$KIND" -v seeder="$SEEDER" -f "$SCANNER" \
            "$TMP_DIR/lines" "$TMP_DIR/source")
    done
    
    TOTAL=$((ERRORS + WARNINGS))
    
    if [[ $TOTAL -eq 0 ]]; then
        echo "✓ No anti-patterns detected in staged changes."
        exit 0
    fi
    
    echo "Anti-patterns detected in staged changes:"
    echo ""
    cat "$FINDINGS"
    echo ""
    echo "$ERRORS error(s), $WARNINGS warning(s)."
    
    if [[ "${CWM_ANTIPATTERN_WARN_ONLY:-0}" == "1" ]]; then
        echo "CWM_ANTIPATTERN_WARN_ONLY=1 — reporting only, commit allowed."
        exit 0
    fi
    
    echo "Fix the findings above, suppress a line with '// cwm:ignore <rule>',"
    echo "or run 'git commit --no-verify' to skip this check."
    exit 1
    
  • hooks/pre-commit-format.shGitHub

All 7 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

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 withdotnet-claude-kit

Make Claude Code a .NET 10 Expert

Get the whole plugin