Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,7 @@ jobs:
run: bash hooks/scripts/check-vgv-cli_test.sh
- name: Warn missing MCP hook tests
run: bash hooks/scripts/warn-missing-mcp_test.sh
- name: Analyze hook tests
run: bash hooks/scripts/analyze_test.sh
- name: Format hook tests
run: bash hooks/scripts/format_test.sh
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ This plugin includes SessionStart, PreToolUse, and PostToolUse hooks that valida
| **Check VGV CLI** (`check-vgv-cli.sh`) | PreToolUse (`mcp__.*very-good-cli__.*`) | Verifies from the payload that the caller is a Very Good CLI tool and stands aside otherwise, so the decision never lands on an unrelated tool; for its own tools, auto-approves the call in every run mode via a PreToolUse `allow` decision, so they never dead-end when the tool isn't on `permissions.allow` (including under `skipAutoPermissionPrompt`); denies with an install/upgrade message if the CLI is missing or < 1.3.0 |
| **Block CLI Workarounds** (`block-cli-workarounds.sh`) | PreToolUse (`Bash`) | Blocks direct CLI bypass of Very Good CLI commands through the host's shell tool; inspects the command only when the payload identifies a shell call, so an unrelated tool carrying a `command` argument is left alone; when the CLI is present but cannot run because `dart` is missing from `PATH`, the denial says so instead of redirecting to an MCP server that cannot start; exits 2 on failure (blocking) |
| **Allow Read-only Git** (`allow-readonly-git.sh`) | PreToolUse (`Bash`, `flutter-reviewer` agent only) | Restricts the `flutter-reviewer` agent's Bash to single-line `git diff`/`git status`, without the `--output` or `--ext-diff` options, and denies anything else (blocking). Scoped via the agent's frontmatter, not `hooks.json` |
| **Analyze** (`analyze.sh`) | PostToolUse (`Edit`/`Write`) | Runs `dart analyze` on the modified `.dart` file; exits 2 on failure (blocking — Claude must fix issues before continuing) |
| **Format** (`format.sh`) | PostToolUse (`Edit`/`Write`) | Runs `dart format` on the modified `.dart` file; always exits 0 (non-blocking — formatting is applied silently) |
| **Analyze** (`analyze.sh`) | PostToolUse (`Edit`/`Write`) | Runs `dart analyze` on each modified `.dart` file; exits 2 on failure (blocking — Claude must fix issues before continuing). Takes the files from `tool_input.file_path`, or as arguments when a host that reports edits differently passes them that way |
| **Format** (`format.sh`) | PostToolUse (`Edit`/`Write`) | Runs `dart format` on each modified `.dart` file, found the same way as Analyze; always exits 0 (non-blocking — formatting is applied silently) |

### Prerequisites

Expand Down
34 changes: 17 additions & 17 deletions hooks/scripts/analyze.sh
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
#!/bin/bash
# PostToolUse hook: run `dart analyze` on each edited Dart file and block on any issue.
# The files come from the arguments, or from the payload when there are none; see
# hook_file_paths in vgv-cli-common.sh.
set -euo pipefail

# Read the hook payload from stdin
input=$(cat)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/vgv-cli-common.sh"

# Check jq availability
if ! command -v jq &>/dev/null; then
# Only the payload needs jq to read
if [ "$#" -eq 0 ] && ! command -v jq &>/dev/null; then
echo "analyze hook: jq not found, skipping" >&2
exit 0
fi

# Extract file path from the tool input
file_path=$(jq -r '.tool_input.file_path // empty' <<< "$input")

# Skip if no file path or not a Dart file
if [[ -z "$file_path" || "$file_path" != *.dart ]]; then
exit 0
fi

# Run dart analyze on the single file
output=$(dart analyze "$file_path" 2>&1) || {
echo "$output" >&2
exit 2
}
# Analyze every Dart file, so one run reports every issue, then block if any failed
status=0
while IFS= read -r file_path; do
[[ "$file_path" == *.dart ]] || continue
output=$(dart analyze "$file_path" 2>&1) || {
echo "$output" >&2
status=2
}
done < <(hook_file_paths "$@")
exit "$status"
136 changes: 136 additions & 0 deletions hooks/scripts/analyze_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/bin/bash
# Tests for analyze.sh
#
# Usage: bash hooks/scripts/analyze_test.sh
#
# The hook runs `dart analyze` on each Dart file it is given: the paths passed as
# arguments, or tool_input.file_path from the JSON payload on stdin when there are none.
# Every case runs against a stubbed dart on a PATH that contains nothing else, and asserts
# on exactly which files the stub was asked to analyze, so results do not depend on a Dart
# SDK being installed.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="$SCRIPT_DIR/analyze.sh"

# Invoked by absolute path: `env -i PATH=...` resolves the command it runs with the PATH
# it was just given, and the no-jq PATH below deliberately holds almost nothing.
BASH_BIN="$(command -v bash)"

PASSED=0
FAILED=0

STUB_DIR="$(mktemp -d)"
trap 'rm -rf "$STUB_DIR"' EXIT

BASE_PATH="$(dirname "$(command -v jq)"):/usr/bin:/bin"

# A stubbed dart that logs its arguments, one call per line, and exits with the status in
# dart.exit (default 0). On a non-zero exit it prints an issue the way `dart analyze` does.
cat > "$STUB_DIR/dart" <<'STUB'
#!/bin/sh
echo "$*" >> "$(dirname "$0")/dart.log"
status=$(cat "$(dirname "$0")/dart.exit" 2>/dev/null || echo 0)
[ "$status" -ne 0 ] && echo " error - lib/a.dart:1:1 - Undefined name 'x'. - undefined_identifier"
exit "$status"
STUB
chmod +x "$STUB_DIR/dart"

dart_exits() { echo "$1" > "$STUB_DIR/dart.exit"; }

# A PATH with the coreutils the hook needs besides jq, and nothing else. Built from
# symlinks rather than named as /bin, because on a merged-/usr Linux /bin is /usr/bin and
# so still has jq on it.
NOJQ_DIR="$STUB_DIR/nojq"
mkdir -p "$NOJQ_DIR"
for tool in cat dirname; do ln -s "$(command -v "$tool")" "$NOJQ_DIR/$tool"; done
NOJQ_PATH="$STUB_DIR:$NOJQ_DIR"

# Run the hook on a payload, with any hook arguments after the PATH. Leaves the exit status in LAST_STATUS, stderr in LAST_STDERR,
# and every `dart` invocation (one per line, e.g. "analyze /w/lib/a.dart") in LAST_CALLS.
LAST_STATUS=0
LAST_STDERR=""
LAST_CALLS=""
run_hook() {
local payload="$1" path="${2:-$STUB_DIR:$BASE_PATH}"
shift; [ "$#" -eq 0 ] || shift
: > "$STUB_DIR/dart.log"
LAST_STATUS=0
LAST_STDERR=$(printf '%s' "$payload" | env -i PATH="$path" "$BASH_BIN" "$HOOK" "$@" 2>&1 >/dev/null) || LAST_STATUS=$?
LAST_CALLS=$(cat "$STUB_DIR/dart.log")
}

pass() { printf " \033[32mPASS\033[0m %s\n" "$1"; PASSED=$((PASSED + 1)); }
fail() { printf " \033[31mFAIL\033[0m %s\n %s\n" "$1" "$2"; FAILED=$((FAILED + 1)); }

# Usage: assert_calls <label> <expected calls, newline separated>
assert_calls() {
local label="$1" expected="$2"
if [ "$LAST_CALLS" = "$expected" ]; then pass "$label"; else fail "$label" "dart was called with: $(printf '%s' "$LAST_CALLS" | tr '\n' '|')"; fi
}

# Usage: assert_status <label> <expected exit>
assert_status() {
local label="$1" expected="$2"
if [ "$LAST_STATUS" -eq "$expected" ]; then pass "$label"; else fail "$label" "exit was $LAST_STATUS, expected $expected"; fi
}


echo "=== analyze tests ==="

echo ""
echo "--- Claude Code: one file in tool_input.file_path ---"
run_hook "$(jq -n '{cwd:"/w", tool_name:"Edit", tool_input:{file_path:"/w/lib/a.dart", old_string:"x", new_string:"y"}, tool_response:{filePath:"/w/lib/a.dart", success:true}}')"
assert_calls "analyzes the edited file" "analyze /w/lib/a.dart"
assert_status "exits 0 when analysis passes" 0

run_hook "$(jq -n '{cwd:"/w", tool_name:"Write", tool_input:{file_path:"/w/README.md", content:"# hi"}, tool_response:{success:true}}')"
assert_calls "skips a file that is not Dart" ""
assert_status "exits 0 for a non-Dart file" 0

echo ""
echo "--- Files passed as arguments ---"
run_hook '{}' "" /abs/lib/x.dart
assert_calls "analyzes a file given as an argument" "analyze /abs/lib/x.dart"

run_hook "$(jq -n '{tool_input:{file_path:"/w/lib/payload.dart"}}')" "" /w/lib/arg.dart
assert_calls "prefers the arguments over the payload" "analyze /w/lib/arg.dart"

run_hook '{}' "" /w/lib/a.dart /w/lib/b.dart "/w/lib/with space.dart"
assert_calls "analyzes every argument" $'analyze /w/lib/a.dart\nanalyze /w/lib/b.dart\nanalyze /w/lib/with space.dart'

run_hook '{}' "" /w/lib/a.dart /w/README.md
assert_calls "skips a non-Dart argument" "analyze /w/lib/a.dart"

run_hook '{}' "$NOJQ_PATH" /w/lib/a.dart
assert_calls "needs no jq when given arguments" "analyze /w/lib/a.dart"

echo ""
echo "--- Payloads that name no file ---"
run_hook "$(jq -n '{cwd:"/w", tool_name:"Bash", tool_input:{command:"ls"}, tool_response:"Exit code: 0\nOutput:\nlib\n"}')"
assert_calls "runs nothing for a shell call" ""
assert_status "exits 0 for a shell call" 0

echo ""
echo "--- Failures block ---"
dart_exits 1
run_hook "$(jq -n '{cwd:"/w", tool_input:{file_path:"/w/lib/a.dart"}}')"
assert_status "exits 2 when analysis fails" 2
if [[ "$LAST_STDERR" == *"undefined_identifier"* ]]; then pass "forwards the analyzer output on stderr"; else fail "forwards the analyzer output on stderr" "stderr was: $LAST_STDERR"; fi

run_hook '{}' "" /w/lib/a.dart /w/lib/b.dart
assert_calls "still analyzes every file after one fails" $'analyze /w/lib/a.dart\nanalyze /w/lib/b.dart'
assert_status "exits 2 when any file fails" 2
dart_exits 0

echo ""
echo "--- Without jq ---"
run_hook "$(jq -n '{tool_input:{file_path:"/w/lib/a.dart"}}')" "$NOJQ_PATH"
assert_calls "runs nothing when jq is missing" ""
assert_status "exits 0 when jq is missing" 0
if [[ "$LAST_STDERR" == *"jq not found, skipping"* ]]; then pass "says why it skipped"; else fail "says why it skipped" "stderr was: $LAST_STDERR"; fi

echo ""
echo "=== Results: $PASSED passed, $FAILED failed ==="
[ "$FAILED" -eq 0 ] || exit 1
25 changes: 11 additions & 14 deletions hooks/scripts/format.sh
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
#!/bin/bash
# PostToolUse hook: run `dart format` on each edited Dart file, never blocking.
# The files come from the arguments, or from the payload when there are none; see
# hook_file_paths in vgv-cli-common.sh.
set -euo pipefail

# Read the hook payload from stdin
input=$(cat)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/vgv-cli-common.sh"

# Check jq availability
if ! command -v jq &>/dev/null; then
# Only the payload needs jq to read
if [ "$#" -eq 0 ] && ! command -v jq &>/dev/null; then
echo "format hook: jq not found, skipping" >&2
exit 0
fi

# Extract file path from the tool input
file_path=$(jq -r '.tool_input.file_path // empty' <<< "$input")

# Skip if no file path or not a Dart file
if [[ -z "$file_path" || "$file_path" != *.dart ]]; then
exit 0
fi

# Run dart format on the single file (auto-fix, always exit 0)
dart format "$file_path" &>/dev/null || true
while IFS= read -r file_path; do
[[ "$file_path" == *.dart ]] || continue
dart format "$file_path" &>/dev/null || true
done < <(hook_file_paths "$@")
105 changes: 105 additions & 0 deletions hooks/scripts/format_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/bin/bash
# Tests for format.sh
#
# Usage: bash hooks/scripts/format_test.sh
#
# The hook runs `dart format` on each Dart file it is given: the paths passed as
# arguments, or tool_input.file_path from the JSON payload on stdin when there are none.
# It never blocks. Every case runs against a stubbed dart on a PATH that contains nothing
# else and asserts on which files the stub was asked to format.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="$SCRIPT_DIR/format.sh"

# Invoked by absolute path: `env -i PATH=...` resolves the command it runs with the PATH
# it was just given, and the no-jq PATH below deliberately holds almost nothing.
BASH_BIN="$(command -v bash)"

PASSED=0
FAILED=0

STUB_DIR="$(mktemp -d)"
trap 'rm -rf "$STUB_DIR"' EXIT

BASE_PATH="$(dirname "$(command -v jq)"):/usr/bin:/bin"

cat > "$STUB_DIR/dart" <<'STUB'
#!/bin/sh
echo "$*" >> "$(dirname "$0")/dart.log"
exit "$(cat "$(dirname "$0")/dart.exit" 2>/dev/null || echo 0)"
STUB
chmod +x "$STUB_DIR/dart"

dart_exits() { echo "$1" > "$STUB_DIR/dart.exit"; }

# A PATH with the coreutils the hook needs besides jq, and nothing else. Built from
# symlinks rather than named as /bin, because on a merged-/usr Linux /bin is /usr/bin and
# so still has jq on it.
NOJQ_DIR="$STUB_DIR/nojq"
mkdir -p "$NOJQ_DIR"
for tool in cat dirname; do ln -s "$(command -v "$tool")" "$NOJQ_DIR/$tool"; done
NOJQ_PATH="$STUB_DIR:$NOJQ_DIR"

LAST_STATUS=0
LAST_CALLS=""
run_hook() {
local payload="$1" path="${2:-$STUB_DIR:$BASE_PATH}"
shift; [ "$#" -eq 0 ] || shift
: > "$STUB_DIR/dart.log"
LAST_STATUS=0
printf '%s' "$payload" | env -i PATH="$path" "$BASH_BIN" "$HOOK" "$@" >/dev/null 2>&1 || LAST_STATUS=$?
LAST_CALLS=$(cat "$STUB_DIR/dart.log")
}

pass() { printf " \033[32mPASS\033[0m %s\n" "$1"; PASSED=$((PASSED + 1)); }
fail() { printf " \033[31mFAIL\033[0m %s\n %s\n" "$1" "$2"; FAILED=$((FAILED + 1)); }

assert_calls() {
local label="$1" expected="$2"
if [ "$LAST_CALLS" = "$expected" ]; then pass "$label"; else fail "$label" "dart was called with: $(printf '%s' "$LAST_CALLS" | tr '\n' '|')"; fi
}

assert_status() {
local label="$1" expected="$2"
if [ "$LAST_STATUS" -eq "$expected" ]; then pass "$label"; else fail "$label" "exit was $LAST_STATUS, expected $expected"; fi
}


echo "=== format tests ==="

echo ""
echo "--- Claude Code ---"
run_hook "$(jq -n '{cwd:"/w", tool_name:"Edit", tool_input:{file_path:"/w/lib/a.dart"}, tool_response:{success:true}}')"
assert_calls "formats the edited file" "format /w/lib/a.dart"
assert_status "exits 0" 0

run_hook "$(jq -n '{cwd:"/w", tool_input:{file_path:"/w/pubspec.yaml"}}')"
assert_calls "skips a file that is not Dart" ""

echo ""
echo "--- Files passed as arguments ---"
run_hook '{}' "" /w/lib/new.dart /w/lib/old.dart /w/lib/notes.md
assert_calls "formats every Dart argument" $'format /w/lib/new.dart\nformat /w/lib/old.dart'
assert_status "exits 0" 0

run_hook '{}' "$NOJQ_PATH" /w/lib/a.dart
assert_calls "needs no jq when given arguments" "format /w/lib/a.dart"

echo ""
echo "--- Never blocks ---"
dart_exits 1
run_hook "$(jq -n '{cwd:"/w", tool_input:{file_path:"/w/lib/a.dart"}}')"
assert_status "exits 0 even when dart format fails" 0
dart_exits 0

echo ""
echo "--- Without jq ---"
run_hook "$(jq -n '{tool_input:{file_path:"/w/lib/a.dart"}}')" "$NOJQ_PATH"
assert_calls "runs nothing when jq is missing" ""
assert_status "exits 0 when jq is missing" 0

echo ""
echo "=== Results: $PASSED passed, $FAILED failed ==="
[ "$FAILED" -eq 0 ] || exit 1
12 changes: 12 additions & 0 deletions hooks/scripts/vgv-cli-common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,15 @@ check_vgv_cli() {
fi
echo "ok"
}

# Print the files a PostToolUse hook should act on, one per line: its arguments when it
# was given any, otherwise tool_input.file_path from the payload on stdin. Claude Code
# names the edited file in the payload. A host that reports an edit some other way passes
# the files as arguments instead, so the script never has to read a second payload shape.
hook_file_paths() {
if [ "$#" -gt 0 ]; then
printf '%s\n' "$@"
else
jq -r '.tool_input.file_path // empty'
fi
}
Loading