Skip to content
Merged
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: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ The `hooks/` directory contains SessionStart, PreToolUse, and PostToolUse hooks

These run **when a session begins**:

- `warn-missing-mcp.sh` — checks if Very Good CLI is installed and >= 1.5.0; outputs a warning to Claude's context if missing, outdated, or present but unable to run because `dart` is not on the `PATH` hooks inherit (non-blocking)
- `warn-missing-mcp.sh` — checks if Very Good CLI is installed and >= 1.5.0; outputs a warning to Claude's context if missing, outdated, or silent about its version (non-blocking). It names which of the two causes it is: `dart` is not on the `PATH` hooks inherit, or a Dart SDK upgrade left a stale snapshot that `very_good --version` rebuilds

### PreToolUse Hooks

These run **before** a tool call is executed:

- `mcp__.*very-good-cli__.*` matcher → `check-vgv-cli.sh` — reads `tool_name` from the payload and exits 0 unless the caller is a Very Good CLI tool, so a host that does not apply the matcher cannot have the decision land on an unrelated tool; for its own tools it auto-approves the call by returning a PreToolUse `allow` decision, so it is always permitted regardless of run mode (interactive, headless, or `skipAutoPermissionPrompt`) and never dead-ends when the tool isn't on `permissions.allow`; denies with an install/upgrade message if the CLI is missing or < 1.5.0, and stands aside when the CLI is present but its version cannot be read (`dart` missing from `PATH`), leaving normal permission handling to apply. The `.*` in the matcher covers both the bare `mcp__very-good-cli__*` server (repo-root `.mcp.json`) and the plugin-namespaced `mcp__plugin_<plugin>_very-good-cli__*` form used when installed from a marketplace
- `mcp__.*very-good-cli__.*` matcher → `check-vgv-cli.sh` — reads `tool_name` from the payload and exits 0 unless the caller is a Very Good CLI tool, so a host that does not apply the matcher cannot have the decision land on an unrelated tool; for its own tools it auto-approves the call by returning a PreToolUse `allow` decision, so it is always permitted regardless of run mode (interactive, headless, or `skipAutoPermissionPrompt`) and never dead-ends when the tool isn't on `permissions.allow`; denies with an install/upgrade message if the CLI is missing or < 1.5.0, and stands aside when the CLI is present but its version cannot be read, for either reason, leaving normal permission handling to apply. The `.*` in the matcher covers both the bare `mcp__very-good-cli__*` server (repo-root `.mcp.json`) and the plugin-namespaced `mcp__plugin_<plugin>_very-good-cli__*` form used when installed from a marketplace
- `Bash` matcher → `block-cli-workarounds.sh` — denies `flutter`/`dart` `test`/`create` and `very_good test`/`create`/`packages` in the shell and points to the MCP tool. Quoted text is data unless `eval`, `sh -c` or `$( )` runs it. A heredoc body with a quoted delimiter (`<<'EOF'`) is data too, unless the opening line feeds it to a shell (`bash <<'EOF'`, `cat <<'EOF' | sh`), which runs it; an unquoted `<<EOF` expands, so its body is always scanned. Every adjacent word pair is checked, so wrappers (`fvm`, `melos`, `sudo`, `timeout`) and `/path/to/flutter` need no list. The denial names the match and says the whole call was refused. Pinned gaps: `"flutter" test` and `$F test` pass; an unquoted pair is denied wherever it sits, including as an argument (`echo flutter test`, `man flutter test`); narrowing the match to a command position would need the wrapper list the pair rule exists to avoid

The first two PreToolUse hooks are plugin-level (defined in `hooks.json`) and share common utilities
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ This plugin includes SessionStart, PreToolUse, and PostToolUse hooks that valida

| Hook | Trigger | Behavior |
| ---- | ------- | -------- |
| **Warn Missing MCP** (`warn-missing-mcp.sh`) | SessionStart | Warns if the Very Good CLI is missing, older than 1.5.0, or present but unable to run because `dart` is not on the `PATH` hooks inherit; non-blocking |
| **Warn Missing MCP** (`warn-missing-mcp.sh`) | SessionStart | Warns if the Very Good CLI is missing, older than 1.5.0, or does not report a version. For that last case it names the cause: either `dart` is not on the `PATH` hooks inherit, or a Dart SDK upgrade left a stale snapshot that `very_good --version` rebuilds. Never blocks |
| **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.5.0 |
| **Block CLI Workarounds** (`block-cli-workarounds.sh`) | PreToolUse (`Bash`) | Denies `flutter`/`dart` `test`/`create` and `very_good test`/`create`/`packages` in the shell and points to the matching MCP tool. Quoted text is not read as a command, so searching for those strings is fine. A denial refuses the whole shell call, so anything chained with the blocked command has to be run again |
| **Block CLI Workarounds** (`block-cli-workarounds.sh`) | PreToolUse (`Bash`) | Denies `flutter`/`dart` `test`/`create` and `very_good test`/`create`/`packages` in the shell and points to the matching MCP tool. Quoted text is not read as a command, so searching for those strings is fine. When `dart` is missing from `PATH` the denial says so instead of pointing at a server that cannot start. A denial refuses the whole shell call, so anything chained with the blocked command has to be run again |
| **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) |
Expand Down
11 changes: 7 additions & 4 deletions evals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,11 @@ Negative controls use the same grader with `min: 0` and `max: 0`.
- **Check both arms answered.** A no-plugin arm that asks a clarifying question instead of
doing the work makes every grader look discriminating. That is a prompt that is not
self-contained, not a result.
- **The plugin's SessionStart hook fires inside every run.** `check_vgv_cli` returns
`unverifiable` there — `very_good` resolves on PATH but `very_good --version` answers
nothing, because it is a shim that execs `dart` under a throwaway `$HOME`. That does not
- **The plugin's SessionStart hook fires inside every run.** `check_vgv_cli` comes back
inconclusive there — `very_good` resolves on PATH but `very_good --version` answers
nothing, because it is a shim that execs `dart` under a throwaway `$HOME`. Which of its
two inconclusive states you get (`dart_missing` or `unverifiable`) depends on whether
`dart` resolves in the run, and each prints a different notice. That does not
keep the hook quiet; it emits a notice, and an earlier wording of it asserted the MCP
server "will not start", which is false under mocks. Cases then opened with that blocker
instead of the answer and their rubrics failed. The notice now states only what is known
Expand Down Expand Up @@ -299,7 +301,8 @@ Four things that are not obvious:
keeps `roots` out of reach — a separate tool whose real handshake a mock cannot perform.
Adding one later is additive: capture it from the same probe, drop in a `<tool>.md`.

The mocks are reachable only when `check_vgv_cli` returns `unverifiable`. In a run
The mocks are reachable only when `check_vgv_cli` comes back inconclusive, in either
state. In a run
`very_good` resolves on PATH but `very_good --version` answers nothing, because it is a
shim that execs `dart` under a throwaway `$HOME`. Read as `not_installed`, the PreToolUse
hook denies the call and the model gets the hook's text as the tool result.
Expand Down
9 changes: 7 additions & 2 deletions hooks/scripts/block-cli-workarounds.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,15 @@ deny_with_cli_check() {
outdated:*)
reason="Very Good CLI ${cli_status#outdated:} is too old (requires >= ${MIN_VERSION}). Update with: dart pub global activate very_good_cli."
;;
unverifiable)
dart_missing)
# Redirecting to the MCP tool here would be a dead end: the server starts through
# the same very_good shim, which cannot exec dart from this PATH either.
reason="Very Good CLI was found but could not run: dart is not on the PATH available to hooks, so the very_good_cli MCP server cannot start either. Add the Dart SDK bin directory to PATH for non-interactive shells (e.g. in ~/.zprofile) and start a new session."
reason="Very Good CLI was found but could not run: dart is not on the PATH available to hooks, so the very_good_cli MCP server cannot start either. Put the Dart SDK bin directory on the PATH your login shell always exports, or start Claude Code from a shell where dart already resolves, then begin a new session."
;;
unverifiable)
# dart resolves, so the server is not a dead end. Redirect as usual, and say how
# to clear a stale snapshot if the tool does turn out to be the thing failing.
reason="$mcp_hint If that tool fails (for example with 'Invalid SDK hash'), the Dart SDK most likely changed under the installed Very Good CLI: run 'very_good --version' once in a terminal to rebuild it, then reconnect the server with /mcp."
;;
*)
reason="$mcp_hint"
Expand Down
31 changes: 29 additions & 2 deletions hooks/scripts/block-cli-workarounds_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,19 @@ FAILED=0
STUB_DIR="$(mktemp -d)"
trap 'rm -rf "$STUB_DIR"' EXIT

BASE_PATH="$(dirname "$(command -v jq)"):/usr/bin:/bin:/usr/sbin:/sbin"
# jq reaches the hook through a directory holding nothing but jq. Putting jq's own
# directory on PATH drags its neighbours in with it, and on a Homebrew machine those
# include dart, which no_dart then cannot make disappear.
mkdir -p "$STUB_DIR/jq-bin"
ln -s "$(command -v jq)" "$STUB_DIR/jq-bin/jq"
BASE_PATH="$STUB_DIR/jq-bin:/usr/bin:/bin:/usr/sbin:/sbin"

# The dart_missing cases need a PATH on which dart does not resolve. Say so outright
# rather than quietly exercise the other branch.
if PATH="$BASE_PATH" command -v dart >/dev/null 2>&1; then
echo "BASE_PATH resolves dart; the dart_missing cases cannot run here" >&2
exit 1
fi

# Install a stubbed very_good. With a version argument the stub reports that version;
# with no argument it fails the way the real shim does when `dart` is missing from PATH.
Expand All @@ -32,13 +44,19 @@ stub_cli() {
if [ -z "$version" ]; then
printf '#!/bin/sh\necho "very_good: dart: command not found" >&2\nexit 127\n' > "$target"
else
printf '#!/bin/sh\necho "very_good %s"\n' "$version" > "$target"
printf '#!/bin/sh\necho "%s"\n' "$version" > "$target"
fi
chmod +x "$target"
}

no_cli() { rm -f "$STUB_DIR/very_good"; }

# Install or remove a stubbed dart. The hooks only probe for its presence, so the stub
# needs no behavior: it is what separates "dart is off this PATH" from "dart runs and
# the version is still unreadable", which carry different diagnoses.
stub_dart() { printf '#!/bin/sh\nexit 0\n' > "$STUB_DIR/dart"; chmod +x "$STUB_DIR/dart"; }
no_dart() { rm -f "$STUB_DIR/dart"; }

# Run the hook on a payload. Sets LAST_RESULT to the permissionDecision ("deny"/"allow"),
# "aside" when the hook exited 0 with no decision, or "exit:<status>" on a non-zero exit.
# The full stdout is left in LAST_OUTPUT for reason assertions. These are globals rather
Expand Down Expand Up @@ -381,6 +399,15 @@ stub_cli
assert_blocked "flutter test"
assert_reason_contains "dart is not on the PATH" "CLI that cannot run points at PATH, not the MCP tool"

# With dart on PATH the shim runs, so the MCP server is not a dead end and the reason
# must still redirect to it rather than blame PATH.
stub_dart
stub_cli
assert_blocked "flutter test"
assert_reason_contains "MCP 'test' tool" "unreadable version with dart present still redirects"
assert_reason_contains "very_good --version" "unreadable version with dart present asks for a rebuild"
no_dart

echo ""
echo "--- Deny reason says the whole call was refused ---"

Expand Down
6 changes: 3 additions & 3 deletions hooks/scripts/check-vgv-cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ case "$cli_status" in
version="${cli_status#outdated:}"
deny "Very Good CLI ${version} is too old. This tool requires Very Good CLI >= ${MIN_VERSION}. Update with: dart pub global activate very_good_cli"
;;
unverifiable)
# The CLI is present but its version could not be read. Stand aside rather than
# deny a genuine call on an inconclusive check; normal permission handling applies.
dart_missing | unverifiable)
# Inconclusive, for either reason. Stand aside rather than deny a genuine call on an
# inconclusive check; normal permission handling applies.
exit 0
;;
esac
Expand Down
16 changes: 14 additions & 2 deletions hooks/scripts/check-vgv-cli_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,19 @@ FAILED=0
STUB_DIR="$(mktemp -d)"
trap 'rm -rf "$STUB_DIR"' EXIT

BASE_PATH="$(dirname "$(command -v jq)"):/usr/bin:/bin:/usr/sbin:/sbin"
# jq reaches the hook through a directory holding nothing but jq. Putting jq's own
# directory on PATH drags its neighbours in with it, and on a Homebrew machine those
# include dart, which no_dart then cannot make disappear.
mkdir -p "$STUB_DIR/jq-bin"
ln -s "$(command -v jq)" "$STUB_DIR/jq-bin/jq"
BASE_PATH="$STUB_DIR/jq-bin:/usr/bin:/bin:/usr/sbin:/sbin"

# The dart_missing cases need a PATH on which dart does not resolve. Say so outright
# rather than quietly exercise the other branch.
if PATH="$BASE_PATH" command -v dart >/dev/null 2>&1; then
echo "BASE_PATH resolves dart; the dart_missing cases cannot run here" >&2
exit 1
fi

# Install a stubbed very_good. With a version argument the stub reports that version;
# with no argument it fails the way the real shim does when `dart` is missing from PATH.
Expand All @@ -37,7 +49,7 @@ stub_cli() {
if [ -z "$version" ]; then
printf '#!/bin/sh\necho "very_good: dart: command not found" >&2\nexit 127\n' > "$target"
else
printf '#!/bin/sh\necho "very_good %s"\n' "$version" > "$target"
printf '#!/bin/sh\necho "%s"\n' "$version" > "$target"
fi
chmod +x "$target"
}
Expand Down
22 changes: 15 additions & 7 deletions hooks/scripts/vgv-cli-common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,21 +72,29 @@ resolve_vgv_bin() {
}

# Check Very Good CLI availability and version.
# Returns: "ok", "not_installed", "outdated:<version>", or "unverifiable".
# Returns: "ok", "not_installed", "outdated:<version>", "dart_missing", or "unverifiable".
#
# "unverifiable" means the binary was found but its version could not be read. The
# installed very_good is a shell shim that execs `dart`, so a PATH without dart on it
# makes the check inconclusive. Callers must not report that as "not installed".
# The last two both mean the version could not be read, so callers must not report
# either as "not installed". dart_missing is a PATH without dart, which the very_good
# shim needs to exec at all; unverifiable is dart present and the version still empty.
check_vgv_cli() {
local vgv_bin
if ! vgv_bin=$(resolve_vgv_bin); then
echo "not_installed"
return
fi
RAW=$("$vgv_bin" --version 2>/dev/null)
VERSION=$(echo "$RAW" | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
# A stale snapshot sends the shim through `dart pub global run`, which prints pub's
# chatter to stdout ahead of the version, so the match must look past the first line.
# Anchoring keeps a version-shaped fragment of a path in that chatter from winning.
VERSION=$("$vgv_bin" --version 2>/dev/null | grep -m1 -oE '^[0-9]+\.[0-9]+\.[0-9]+$')
if [ -z "$VERSION" ]; then
echo "unverifiable"
# Blaming PATH for every unreadable version sends users to edit profiles that were
# never at fault, so only say that when dart really is absent.
if command -v dart &>/dev/null; then
echo "unverifiable"
else
echo "dart_missing"
fi
return
fi
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
Expand Down
12 changes: 7 additions & 5 deletions hooks/scripts/warn-missing-mcp.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/vgv-cli-common.sh"

# Neither unreadable-version notice may imply the server is down: hooks and the MCP client
# do not share a PATH, and under `claude plugin eval` the server is mocked and answers
# anyway. Say what is known and tell the session to carry on. See evals/README.md.
cli_status=$(check_vgv_cli)
case "$cli_status" in
not_installed)
Expand All @@ -15,12 +18,11 @@ case "$cli_status" in
version="${cli_status#outdated:}"
echo "⚠️ Very Good CLI ${version} is too old. The Very Good CLI MCP server requires >= ${MIN_VERSION}. Update with: dart pub global activate very_good_cli"
;;
dart_missing)
echo "ℹ️ Very Good CLI is installed but its version could not be verified here: dart is not on the PATH this hook inherits, so ${MIN_VERSION}+ could not be confirmed. If the Very Good CLI MCP tools answer, disregard this and carry on. If they do not, this is the likely cause. Put the Dart SDK bin directory on the PATH your login shell always exports, or start Claude Code from a shell where dart already resolves, then begin a new session."
;;
unverifiable)
# The very_good shim execs dart, so a PATH without dart hides the version from this
# hook. That does not prove the server is down: hooks and the MCP client do not share
# a PATH, and under `claude plugin eval` the server is mocked and always answers. Say
# what is known, not what it implies, or a session spends its answer on a false blocker.
echo "ℹ️ Very Good CLI is installed but its version could not be verified here: dart is not on the PATH this hook inherits, so ${MIN_VERSION}+ could not be confirmed. If the Very Good CLI MCP tools answer, disregard this and carry on. If they do not, this is the likely cause — add the Dart SDK bin directory to PATH for non-interactive shells (e.g. in ~/.zprofile) and start a new session."
echo "ℹ️ Very Good CLI is installed and dart is on the PATH, but the CLI did not report a version, so ${MIN_VERSION}+ could not be confirmed. If the Very Good CLI MCP tools answer, disregard this and carry on. If they fail (for example with 'Invalid SDK hash'), the Dart SDK most likely changed under the installed CLI: run 'very_good --version' once in a terminal to rebuild it, then reconnect the server with /mcp or start a new session."
;;
esac

Expand Down
Loading
Loading