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.
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
- Matches
Bashbash "${CLAUDE_PLUGIN_ROOT}/hooks/pre-bash-guard.sh"
PostToolUse
- Matches
Edit|Writebash "${CLAUDE_PLUGIN_ROOT}/hooks/post-edit-format.sh"bash "${CLAUDE_PLUGIN_ROOT}/hooks/post-scaffold-restore.sh"
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.

