From 1d389df35b32ad9cdf88f0f5eece93263473cc24 Mon Sep 17 00:00:00 2001 From: Dominik Simonik Date: Tue, 8 Sep 2026 15:02:53 +0200 Subject: [PATCH 1/3] feat: add support for Gemini CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the enforcement layer to Gemini CLI so the plugin is not skills-only there. Skills already load unchanged from the Agent Skills standard paths; this adds the MCP wiring, the hooks, the reviewer subagent, and CI that proves all three actually load. - `.gemini/settings.json` registers the same `dart` and `very-good-cli` servers as `.mcp.json` and runs the same hook scripts under Gemini's event names. Generated with `gemini hooks migrate --from-claude`, then corrected for the three things that tool leaves behind: `${CLAUDE_PLUGIN_ROOT}` (no Gemini equivalent for settings-level hooks, so scripts resolve through `${VGV_PLUGIN_ROOT:-$PWD}`), timeouts (Gemini reads milliseconds, Claude reads seconds), and MCP matchers (`mcp__`, not `mcp____`). - The hook scripts read `hook_event_name` and emit whichever response shape the firing harness reads, so one script serves both. Claude Code behavior is untouched: `check-vgv-cli.sh` still auto-approves and `block-cli-workarounds.sh` still steers the quality gates through the MCP tools there. - `.gemini/agents/flutter-reviewer.md` ports the reviewer. Gemini validates agent frontmatter with a strict schema that rejects `skills:` and `hooks:`, so it is a second port rather than a shared file. Its read-only contract is structural instead of hook-enforced: it is granted no shell tool at all. - A `Gemini CLI Loader` CI job asserts all 15 skills load, the subagent passes Gemini's schema, and every hook registers and runs — no credentials and no model call. `gemini-config_test.sh` guards the silent-failure modes statically, and both hook suites now run every case once per harness. Closes #125 Co-Authored-By: Claude Opus 5 --- .gemini/agents/flutter-reviewer.md | 134 +++++++++++++ .gemini/settings.json | 73 +++++++ .github/workflows/ci.yaml | 79 ++++++++ AGENTS.md | 30 ++- CLAUDE.md | 13 ++ CONTRIBUTING.md | 88 +++++++- README.md | 53 +++++ config/cspell.json | 2 + hooks/scripts/allow-readonly-git.sh | 9 +- hooks/scripts/allow-readonly-git_test.sh | 115 ++++++++--- hooks/scripts/block-cli-workarounds.sh | 3 + hooks/scripts/block-cli-workarounds_test.sh | 132 +++++++++--- hooks/scripts/check-vgv-cli.sh | 5 + hooks/scripts/gemini-config_test.sh | 212 ++++++++++++++++++++ hooks/scripts/vgv-cli-common.sh | 50 ++++- 15 files changed, 923 insertions(+), 75 deletions(-) create mode 100644 .gemini/agents/flutter-reviewer.md create mode 100644 .gemini/settings.json create mode 100755 hooks/scripts/gemini-config_test.sh diff --git a/.gemini/agents/flutter-reviewer.md b/.gemini/agents/flutter-reviewer.md new file mode 100644 index 0000000..c2861a7 --- /dev/null +++ b/.gemini/agents/flutter-reviewer.md @@ -0,0 +1,134 @@ +--- +name: flutter-reviewer +display_name: Flutter Reviewer +description: > + Read-only Flutter code reviewer. Dispatch after writing or changing Dart code to review + changed code against VGV bloc, testing, security, and accessibility standards. Never edits files. + Pass the changed Dart files (and the diff, when you have it) in the task description. +tools: + - activate_skill + - read_file + - read_many_files + - glob + - grep_search + - list_directory + - mcp_dart_analyze_files +--- + +# Flutter Reviewer Agent + +You are a read-only Flutter code reviewer for Very Good Ventures. You review changed Dart code +against four VGV standards and report findings as a markdown table. When an orchestrator +dispatches you, it consumes your table verbatim. + +This is the Gemini CLI port of the Claude Code `flutter-reviewer` agent +(`agents/flutter-reviewer.md`). The findings table is identical in both harnesses; how you load +standards and how the read-only contract is enforced differ, and both are described below. + +## Read-only contract + +You **never** edit files. This is enforced by your tool allowlist rather than by your good +intentions: you have no `write_file`, no `replace`, and no `run_shell_command`, so you cannot +write a file, run `git checkout`, run `sed -i`, or redirect output. There is nothing to work +around; the restriction is intentional. + +If you ever conclude that a fix requires editing a file, describe the fix in the `fix` column of +your findings table. Do not apply it. + +## Load your standards first + +Your four standards are Agent Skills, not preloaded context. Before you review anything, load all +four with `activate_skill`: + +- **`bloc`** — Bloc/Cubit state management conventions. +- **`testing`** — unit, widget, and golden test conventions. +- **`static-security`** — Flutter static security review. +- **`accessibility`** — WCAG-aligned Flutter accessibility. + +If `activate_skill` cannot find one of them, say so in a note after the table and review against +the ones that did load. Never substitute your own judgment for a standard you could not read. + +Every finding you report must trace back to one of these four standards. If a problem does not map +to one of them, do not report it (see "What not to report"). + +## Diff scoping + +Scope your review to changed Dart code only. Never review the whole repository. + +You have no shell, so you cannot run `git diff` yourself. The caller supplies the change scope: + +1. **Read the scope from your task description.** The orchestrator runs `git status` and `git diff` + and passes you the changed `.dart` files, and usually the diff itself. Treat that list as the + complete change set, including any untracked `.dart` files it names. +2. **Read the full files, not just the hunks.** Use `read_file` and `read_many_files` on every + changed path, and `grep_search` / `glob` to follow a symbol into the code around it. A diff hunk + alone is not enough context to judge a bloc, a test, or a `Semantics` tree. +3. **Monorepo / subdirectory.** Apply the four standards per affected package. + +When the Dart MCP server is connected, you may use `mcp_dart_analyze_files` to corroborate a +skill-based judgment, but analyzer output is not itself a findings source (see "What not to +report"). If it is unavailable, rely on the four standards alone. + +### When scoping fails + +If your task description names no changed files and you cannot determine a change scope, report +that you could not determine a change scope and ask the caller for the changed file list. Do not +guess and do not review the whole repository. + +## Output + +Output **exactly one** markdown table, one row per finding. Do **not** split findings into multiple +tables, do **not** group them by file, and do **not** introduce section headings or extra columns +around the table. The table has exactly these four columns, in this order — `location`, `problem`, +`fix`, `standard`: + +```markdown +| location | problem | fix | standard | +| --------------------------------- | ---------------------------------------- | ------------------------------------ | -------------- | +| lib/counter/counter_cubit.dart:12 | Mutable state field breaks immutability | Mark state class fields `final` | bloc | +| test/counter/counter_test.dart:30 | Tautological assertion `expect(x, x)` | Assert against the expected value | testing | +``` + +Rules: + +- `location` — `path:line` of the finding, in a single column. Always include the file path on every + row; never move the path into a heading and never reduce this column to a bare line number. +- `problem` — what is wrong, concisely. +- `fix` — the change you recommend. Describe it; never apply it. +- `standard` — exactly one of `bloc`, `testing`, `static-security`, `accessibility`, in its own + column on every row. Every row must name one of these four. Never convey the standard through a + section heading instead of this column. +- Align the pipe characters vertically (VGV markdown convention). + +A one-line note after the table (per "Out-of-domain changes" below) is allowed. Any other prose, +grouping, or additional tables is not. + +### No changed Dart files + +If the change scope contains no `.dart` files (clean tree, or only non-Dart changes), report +`No changed Dart files to review.` and stop. Never emit an empty table and never invent findings. + +### Out-of-domain changes + +Your four standards do not cover every domain. If changed Dart code touches areas outside them — +for example navigation, theming, internationalization, or layered architecture — you have no loaded +standard to cite, so you stay silent on findings there. Add a one-line note after the table listing +the changed areas that fall outside your four standards, so a clean review is not mistaken for full +coverage. For example: + +> Note: changes in `lib/routing/` and `lib/theme/` are outside the loaded standards (bloc, testing, +> static-security, accessibility) and were not reviewed. + +### What not to report + +- **Analyzer-only findings.** Raw `dart analyze` errors (unused imports, dead null-aware operators, + etc.) do not trace to any of your four standards, so they are out of scope for your table. + Do not report them and do not introduce a `dart-analyzer` pseudo-standard. Such errors are caught + separately by the `AfterTool` `analyze.sh` hook when code is written, not here. Use the analyzer + only to corroborate a skill-based judgment. +- **Untraceable findings.** If a finding cannot name one of the four standards, omit it. + +## Dispatch contract + +The caller passes you the changed `.dart` files in the task description and consumes your findings +table verbatim. diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000..b46699f --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,73 @@ +{ + "mcpServers": { + "dart": { + "command": "dart", + "args": [ + "mcp-server", + "--enable", + "dart_format" + ], + "trust": true + }, + "very-good-cli": { + "command": "very_good", + "args": [ + "mcp" + ], + "env": {}, + "trust": true + } + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${VGV_PLUGIN_ROOT:-$PWD}/hooks/scripts/warn-missing-mcp.sh\"", + "timeout": 10000 + } + ] + } + ], + "BeforeTool": [ + { + "matcher": "mcp_very-good-cli_.*", + "hooks": [ + { + "type": "command", + "command": "bash \"${VGV_PLUGIN_ROOT:-$PWD}/hooks/scripts/check-vgv-cli.sh\"", + "timeout": 10000 + } + ] + }, + { + "matcher": "run_shell_command", + "hooks": [ + { + "type": "command", + "command": "bash \"${VGV_PLUGIN_ROOT:-$PWD}/hooks/scripts/block-cli-workarounds.sh\"", + "timeout": 10000 + } + ] + } + ], + "AfterTool": [ + { + "matcher": "replace|write_file", + "hooks": [ + { + "type": "command", + "command": "bash \"${VGV_PLUGIN_ROOT:-$PWD}/hooks/scripts/analyze.sh\"", + "timeout": 30000 + }, + { + "type": "command", + "command": "bash \"${VGV_PLUGIN_ROOT:-$PWD}/hooks/scripts/format.sh\"", + "timeout": 30000 + } + ] + } + ] + } +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 73c0e97..3b052c6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -91,3 +91,82 @@ jobs: run: bash hooks/scripts/allow-readonly-git_test.sh - name: Block CLI workarounds hook tests run: bash hooks/scripts/block-cli-workarounds_test.sh + - name: Gemini CLI config tests + run: bash hooks/scripts/gemini-config_test.sh + gemini-loader: + name: ♊ Gemini CLI Loader + # Asserts the plugin actually loads in Gemini CLI: all 15 skills discovered + # from .agents/skills, the flutter-reviewer subagent accepted by Gemini's + # strict agent schema, and .gemini/settings.json hooks registered under + # event names Gemini fires. No credentials are used and no model is called. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '22.22.0' + - name: Install Gemini CLI + run: npm install -g @google/gemini-cli + - name: Build a probe workspace + # Mirrors how a consumer installs: skills land in .agents/skills (the + # Agent Skills standard path Gemini reads), and .gemini/ carries the + # subagent plus the hook and MCP wiring. mcpServers is stripped because + # this job asserts loading, not tool execution, and `dart` / `very_good` + # are not on the runner. + run: | + set -euo pipefail + WS="$RUNNER_TEMP/gemini-probe" + mkdir -p "$WS/.agents/skills" "$WS/.gemini" + cp -R skills/. "$WS/.agents/skills/" + cp -R .gemini/agents "$WS/.gemini/agents" + cp -R hooks "$WS/hooks" + jq 'del(.mcpServers)' .gemini/settings.json > "$WS/.gemini/settings.json" + # Folder trust is on by default and silently disables workspace + # skills, agents, and hooks in a non-interactive session. + mkdir -p "$HOME/.gemini" + echo '{"security":{"folderTrust":{"enabled":false}}}' > "$HOME/.gemini/settings.json" + echo "WS=$WS" >> "$GITHUB_ENV" + - name: Assert every skill loads + run: | + set -euo pipefail + cd "$WS" + gemini skills list | tee "$RUNNER_TEMP/skills.txt" + expected=$(find -L "$GITHUB_WORKSPACE/skills" -name SKILL.md -type f | + xargs -I {} dirname {} | xargs -n1 basename | sort) + for skill in $expected; do + if ! grep -qE "^${skill} \[Enabled\]" "$RUNNER_TEMP/skills.txt"; then + echo "::error::skill '${skill}' did not load in Gemini CLI" + exit 1 + fi + done + loaded=$(grep -cE '^\S+ \[Enabled\]' "$RUNNER_TEMP/skills.txt") + count=$(echo "$expected" | wc -l | tr -d ' ') + if [ "$loaded" -ne "$count" ]; then + echo "::error::expected ${count} skills to load, Gemini CLI loaded ${loaded}" + exit 1 + fi + echo "All ${count} skills loaded." + - name: Assert the subagent and hooks load + # A headless run reports agent and hook registration before it reaches + # the model, so an unreachable base URL is enough to read the verdict + # without a key, a network call, or a billed request. + run: | + set -uo pipefail + cd "$WS" + GEMINI_API_KEY=unused GEMINI_BASE_URL=http://127.0.0.1:1 \ + gemini -p "noop" > "$RUNNER_TEMP/probe.txt" 2>&1 || true + cat "$RUNNER_TEMP/probe.txt" + status=0 + if grep -q "Agent loading error" "$RUNNER_TEMP/probe.txt"; then + echo "::error::Gemini CLI rejected a subagent in .gemini/agents" + status=1 + fi + if grep -q "Invalid hook event name" "$RUNNER_TEMP/probe.txt"; then + echo "::error::.gemini/settings.json declares a hook event Gemini CLI does not fire" + status=1 + fi + if grep -qE "Hook\(s\) \[.*\] failed" "$RUNNER_TEMP/probe.txt"; then + echo "::error::a hook declared in .gemini/settings.json failed to execute" + status=1 + fi + exit "$status" diff --git a/AGENTS.md b/AGENTS.md index f70cd55..07db5a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,10 @@ VGV AI Flutter Plugin provides best-practices skills for Flutter and Dart develo .mcp.json # MCP server configuration (Dart and Very Good CLI) .claude-plugin/ plugin.json # Plugin manifest (name, version, keywords) +.gemini/ # Gemini CLI port of the enforcement layer + settings.json # Gemini MCP servers + hooks (BeforeTool/AfterTool/SessionStart) + agents/ + flutter-reviewer.md # Gemini-format read-only reviewer subagent agents/ flutter-reviewer.md # Read-only Flutter code reviewer subagent docs/ @@ -46,6 +50,7 @@ hooks/ block-cli-workarounds.sh # Prevents direct CLI bypass via Bash check-vgv-cli.sh # Validates VGV CLI installed and >= 1.3.0 format.sh # Runs dart format on modified .dart files + gemini-config_test.sh # Static checks on the .gemini/ port vgv-cli-common.sh # Shared utilities for VGV CLI hook scripts warn-missing-mcp.sh # Warns at session start if VGV CLI is missing/outdated skills/ # every / ships SKILL.md + agents/openai.yaml (Codex sidecar) @@ -138,9 +143,15 @@ They live in `agents/.md` at the plugin root and are **auto-discovered** - `skills` _(optional)_ — bare skill names to preload at startup (full skill content is injected) - `model` _(optional)_ — `inherit` to use the session model - `hooks` _(optional)_ — agent-scoped hooks, e.g. a PreToolUse `Bash` hook -2. Add an **Agents** table row in `README.md` (agent name links to the `agents/.md` file) -3. Add any new domain terms to the `words` list in `config/cspell.json` -4. Update the repository structure in `AGENTS.md` +2. Port it to `.gemini/agents/.md` for Gemini CLI. The two files are separate + ports of one contract, not one shared file: Gemini validates local agent frontmatter + with a **strict** schema (`kind`, `name`, `description`, `display_name`, `tools`, + `mcp_servers`, `model`, `temperature`, `max_turns`, `timeout_mins`) and drops any agent + carrying a key outside it — `skills` and `hooks` included. `tools` is a YAML list of + Gemini tool names. See `CONTRIBUTING.md` → Gemini CLI +3. Add an **Agents** table row in `README.md` (agent name links to the `agents/.md` file) +4. Add any new domain terms to the `words` list in `config/cspell.json` +5. Update the repository structure in `AGENTS.md` ## Maintaining Existing Skills, Hooks, and MCP Tools @@ -167,10 +178,21 @@ documentation in the same change: automatically, so verify each one by hand. - **Adding or changing a hook** in `hooks/hooks.json` — update the **Hooks** section in `README.md` (and the `## Hooks` section in `CLAUDE.md` if behavior - changes). + changes), and mirror it in `.gemini/settings.json` under Gemini CLI's event + names and tool matchers. Regenerate the Gemini block with + `gemini hooks migrate --from-claude` rather than hand-porting it, then fix what + that tool leaves behind — see `CONTRIBUTING.md` → Gemini CLI. A hook that exists + on only one harness is fine, but say which in the script's header comment. +- **Changing a hook script's response shape** — the deny and allow helpers in + `vgv-cli-common.sh` branch on `hook_event_name`, so any new response field has to + be written for both harnesses. `hooks/scripts/gemini-config_test.sh` guards the + Gemini side of the wiring; the two `*_test.sh` suites run every case twice, once + per harness. - **Adding or changing an MCP tool** — update the **MCP Integration** tools table in `README.md`, and check whether any skill's `allowed-tools` names a tool that was renamed or removed. Nothing validates those names. +- **Adding or removing an MCP server** — register it in both `.mcp.json` and + `.gemini/settings.json`. `gemini-config_test.sh` fails when the two disagree. ## Evals diff --git a/CLAUDE.md b/CLAUDE.md index dbaa815..1e2c3ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,3 +35,16 @@ These run **after** a tool call completes: - `Edit|Write` matcher → `format.sh` — runs `dart format` on the modified `.dart` file; always exits 0 (non-blocking) All hook scripts require **jq** to parse the hook payload (they skip gracefully if `jq` is not installed). + +### Gemini CLI + +`.gemini/settings.json` runs the same scripts under Gemini CLI's event names: `SessionStart` stays +`SessionStart`, `PreToolUse` becomes `BeforeTool`, and `PostToolUse` becomes `AfterTool`, with +matchers rewritten to Gemini's tool names (`run_shell_command`, `replace|write_file`, +`mcp_very-good-cli_.*`) and timeouts in milliseconds rather than seconds. The scripts read +`hook_event_name` off the payload and emit whichever response shape the firing harness reads — +`hookSpecificOutput.permissionDecision` for Claude Code, top-level `decision`/`reason` for Gemini +CLI. Two things do not carry over: Gemini has no auto-approve for a `BeforeTool` hook (so +`check-vgv-cli.sh` only enforces the version gate there, and `"trust": true` on the MCP server +covers approval), and it has no agent-scoped hooks (so `allow-readonly-git.sh` is Claude-only and +the Gemini reviewer holds its read-only contract by granting no shell tool at all). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 108322f..b64408f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,7 +172,9 @@ this as "use the MCP tool if available; otherwise Bash" — match that. plugin dispatches one. The `flutter-reviewer` agent (`agents/flutter-reviewer.md`) is a Claude Code construct; on a host without a subagent mechanism its four preloaded standards (`bloc`, `testing`, `static-security`, `accessibility`) still apply — run the review inline -against those skills instead of dispatching the agent. +against those skills instead of dispatching the agent. Gemini CLI does have a subagent +mechanism, and the reviewer is ported to it at `.gemini/agents/flutter-reviewer.md` — see +[Gemini CLI](#gemini-cli) for why that is a second file rather than a shared one. **`AskUserQuestion` and `allowed-tools`** — both are Claude Code conveniences. A skill that asks the user a structured question carries its own inline fallback: invoke whatever @@ -203,6 +205,87 @@ it **user-invoked**: set `disable-model-invocation: true` in the frontmatter and `policy.allow_implicit_invocation: false` in its `agents/openai.yaml`, and keep the two in sync — a skill is user-invoked in both harnesses or neither. +### Gemini CLI + +Gemini CLI reads the skills as-is; the enforcement layer is ported alongside them in +`.gemini/`. Everything below was checked against **0.52.0** — Gemini CLI moves fast, so +re-check rather than trust this list if a behavior surprises you. + +**Skill discovery** — Gemini reads four directories, all with the same +`/SKILL.md` layout this repo already uses: `.agents/skills` and `~/.agents/skills` +(the Agent Skills standard path, what `npx skills` writes) plus `.gemini/skills` and +`~/.gemini/skills` (what `gemini skills install` writes). No sidecar and no manifest entry +— a spec-valid skill is a Gemini skill. **Folder trust is on by default and silently +disables every workspace-scoped skill, agent, and hook** in an untrusted directory, which +is the first thing to check when nothing loads. + +**Hooks** — mirror `hooks/hooks.json` into `.gemini/settings.json`. Generate the block +with the migration tool rather than by hand: + +```bash +gemini hooks migrate --from-claude +``` + +It reads `.claude/settings.json` (or `.claude/settings.local.json`) in the current +directory, so feed it this plugin's hooks first — `jq '{hooks: .hooks}' hooks/hooks.json > +.claude/settings.json` in a scratch directory — and it writes the converted block to +`.gemini/settings.json`. It maps the event names (`PreToolUse` → `BeforeTool`, +`PostToolUse` → `AfterTool`, `UserPromptSubmit` → `BeforeAgent`, `Stop` → `AfterAgent`, +`PreCompact` → `PreCompress`, `SessionStart` unchanged) and the tool matchers +(`Bash` → `run_shell_command`, `Edit` → `replace`, `Write` → `write_file`, +`Read` → `read_file`). Three things it leaves for you: + +- **Timeouts.** Copied verbatim, but Claude Code reads seconds and Gemini reads + **milliseconds** — a `10` becomes a 10 ms budget that times out before `bash` starts. +- **`${CLAUDE_PLUGIN_ROOT}`.** Only `$CLAUDE_PROJECT_DIR` is rewritten. Gemini has no + plugin-root variable for settings-level hooks, so the committed hooks resolve scripts + through `${VGV_PLUGIN_ROOT:-$PWD}`. Keep that expansion **single-level**: Gemini + resolves `${VAR}` and `${VAR:-default}` in settings values at load time with a + non-recursive regex, so a nested `${A:-${B}}` silently leaves a stray `}` in the path. +- **MCP matchers.** Gemini names MCP tools `mcp__`, so + `mcp__.*very-good-cli__.*` has to become `mcp_very-good-cli_.*`. Server names cannot + contain `_` — `dart` and `very-good-cli` are both fine. + +An unrecognized event name is not an error: Gemini logs `Invalid hook event name: … Skipping.` +and carries on, which is why `hooks/scripts/gemini-config_test.sh` asserts the event names, +the timeout units, and the matcher naming. + +**Hook responses** — Gemini reads a different response shape. Claude Code blocks with +`hookSpecificOutput.permissionDecision`; Gemini blocks with a top-level +`{"decision": "deny", "reason": "…"}` (or exit 2 with the reason on stderr). The `deny` and +`allow` helpers in `hooks/scripts/vgv-cli-common.sh` branch on the payload's +`hook_event_name`, so read it with `read_hook_event "$INPUT"` before calling either. Both +harnesses carry the shell command at `tool_input.command` and the edited path at +`tool_input.file_path`, so payload reads need no branching. A Gemini `BeforeTool` hook +**cannot** pre-approve a call — it blocks or stays out of the way — so `allow` is a no-op +there and `"trust": true` on the MCP server covers approval instead. + +**MCP** — `.gemini/settings.json` takes the same `mcpServers` object as `.mcp.json`, so +both files must register the same servers; `gemini-config_test.sh` fails when they diverge. + +**Subagents** — Gemini validates a local agent's frontmatter with a **strict** schema and +drops the whole agent on any key outside `kind`, `name`, `description`, `display_name`, +`tools`, `mcp_servers`, `model`, `temperature`, `max_turns`, `timeout_mins`. Claude Code's +`skills:` and `hooks:` are both rejected, and `tools` must be a YAML **list** of Gemini tool +names, so the two agent files are separate ports of one contract. Gemini has no agent-scoped +hooks and no argument scoping in `tools` (only exact built-in names, `mcp__`, +or `*`), which means a read-only agent is held read-only by **omitting** `run_shell_command`, +`write_file`, and `replace` rather than by restricting them. Preloaded skills have no +frontmatter equivalent either — grant `activate_skill` and have the agent load its standards +itself. + +**Verifying locally** — both checks run without credentials: + +```bash +gemini skills list # every skill, from .agents/skills or .gemini/skills +GEMINI_API_KEY=unused GEMINI_BASE_URL=http://127.0.0.1:1 gemini -p noop +``` + +The second reports agent and hook registration before it reaches the model, so an +unreachable base URL is enough to read the verdict: `Agent loading error`, +`Invalid hook event name`, and `Hook(s) [...] failed` are the three lines that matter. The +`Gemini CLI Loader` CI job runs exactly these two commands. + ## Testing Locally Editing a skill or hook and pushing straight to a PR only tells you the files @@ -308,7 +391,8 @@ Every pull request runs the following checks automatically: | Spelling | Runs cspell on all `*.md` files | `config/cspell.json` | | Skill validation | Validates **every** `SKILL.md`'s frontmatter and structure against the Agent Skills spec, so a malformed skill fails the build instead of silently vanishing on another host | `Flash-Brew-Digital/validate-skill@v1` | | Plugin validation | Validates and test-installs the plugin | `claude plugin validate .` | -| Script tests | Runs the hook scripts' own test suites | `hooks/scripts/*_test.sh` | +| Script tests | Runs the hook scripts' own test suites, each case once per harness, plus the static checks on the `.gemini/` port | `hooks/scripts/*_test.sh` | +| Gemini CLI loader | Installs the skills into a probe workspace and asserts all 15 load in a real Gemini CLI, the `flutter-reviewer` subagent passes Gemini's strict agent schema, and every `.gemini/settings.json` hook registers and runs. No credentials, no model call | `.github/workflows/ci.yaml` | Evals do **not** run on a pull request. They call real models, so they run after a merge to `main` instead, scoped to the skills that changed: diff --git a/README.md b/README.md index a0075c9..f166838 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ This plugin ships subagents that Claude Code can dispatch as isolated, specializ | ----- | ----------- | | [**Flutter Reviewer**](agents/flutter-reviewer.md) | Read-only reviewer of changed Dart code against the preloaded `bloc`, `testing`, `static-security`, and `accessibility` standards — emits a `location \| problem \| fix \| standard` findings table. Never edits files; Bash is hook-restricted to `git diff`/`git status` | +A Gemini CLI port of the same reviewer ships at [`.gemini/agents/flutter-reviewer.md`](.gemini/agents/flutter-reviewer.md) — see [Gemini CLI](#gemini-cli). + ## Hooks This plugin includes SessionStart, PreToolUse, and PostToolUse hooks that validate the Very Good CLI, guard against CLI bypass, and automatically run Dart analysis and formatting on `.dart` files. @@ -76,6 +78,8 @@ This plugin includes SessionStart, PreToolUse, and PostToolUse hooks that valida | **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) | +The same scripts run on Gemini CLI under its own event names, wired by [`.gemini/settings.json`](.gemini/settings.json) — see [Gemini CLI](#gemini-cli). + ### Prerequisites - **Dart SDK** — must be available on your `PATH` @@ -187,6 +191,55 @@ The Very Good CLI MCP server exposes Very Good CLI commands to Claude. The `.mcp.json` file at the project root registers the `dart` and `very-good-cli` MCP servers using stdio transport. When Claude Code detects this configuration, it connects to both servers and gains access to the tools above. The skills continue to provide knowledge and best practices while the MCP tools handle execution. +[`.gemini/settings.json`](.gemini/settings.json) registers the same two servers for Gemini CLI — see [Gemini CLI](#gemini-cli). + +## Gemini CLI + +The skills follow the [Agent Skills open standard][agent_skills_link], so they run on [Gemini CLI][gemini_cli_link] as well as Claude Code. The enforcement layer — hooks, MCP servers, and the read-only reviewer — is ported alongside them in [`.gemini/`](.gemini). + +Verified against Gemini CLI **0.52.0**. + +### Install the skills + +```bash +gemini skills install https://github.com/VeryGoodOpenSource/vgv-ai-flutter-plugin --path skills +``` + +That installs all 15 skills into `~/.gemini/skills`; add `--scope workspace` to install into `.gemini/skills` in the current project instead. Gemini CLI also reads `.agents/skills/` (and `~/.agents/skills/`), the standard path other Agent Skills installers write to, so a skills-standard install is picked up with no extra wiring. Confirm with `gemini skills list`. + +### Wire the MCP servers and hooks + +Copy [`.gemini/settings.json`](.gemini/settings.json) into your project's `.gemini/settings.json`, or merge its `mcpServers` and `hooks` blocks into the one you already have. It registers the same `dart` and `very-good-cli` servers as `.mcp.json` and runs the same hook scripts: + +| Claude Code | Gemini CLI | Script | +| ----------- | ---------- | ------ | +| `SessionStart` | `SessionStart` | `warn-missing-mcp.sh` | +| `PreToolUse` (`mcp__.*very-good-cli__.*`) | `BeforeTool` (`mcp_very-good-cli_.*`) | `check-vgv-cli.sh` | +| `PreToolUse` (`Bash`) | `BeforeTool` (`run_shell_command`) | `block-cli-workarounds.sh` | +| `PostToolUse` (`Edit`/`Write`) | `AfterTool` (`replace`/`write_file`) | `analyze.sh`, `format.sh` | + +The hook commands resolve their scripts through `${VGV_PLUGIN_ROOT:-$PWD}`. Set `VGV_PLUGIN_ROOT` to wherever you cloned this repository; leave it unset and the scripts are read from the workspace root, which is what makes the committed settings work in this repository as-is. + +Two Claude Code behaviors have no Gemini CLI equivalent and degrade rather than break: + +- **Auto-approving Very Good CLI MCP calls.** A Gemini `BeforeTool` hook can block a call but cannot pre-approve one, so `check-vgv-cli.sh` only enforces the version gate there. `"trust": true` on the server in `.gemini/settings.json` covers the approval side. +- **Agent-scoped hooks.** Gemini CLI has none, so the reviewer's read-only contract is enforced by its tool allowlist instead — see below. + +### The reviewer subagent + +Copy [`.gemini/agents/flutter-reviewer.md`](.gemini/agents/flutter-reviewer.md) into your project's `.gemini/agents/`. Gemini CLI validates local agent frontmatter with a strict schema that rejects Claude Code's `skills:` and `hooks:` keys, so the two agent files are separate ports of one contract rather than one shared file. They emit the identical findings table; what differs is: + +- **Standards loading.** Claude Code preloads `bloc`, `testing`, `static-security`, and `accessibility` through the agent's `skills:` frontmatter. The Gemini agent activates the same four with `activate_skill` at the start of a review. +- **Read-only enforcement.** Claude Code restricts the agent's Bash to `git diff`/`git status` with a PreToolUse hook. The Gemini agent is granted no shell at all, which makes the contract structural — it cannot write a file, `git checkout`, or redirect output. +- **Diff scoping.** With no shell the Gemini agent cannot run `git diff` itself, so the caller passes the changed `.dart` files in the task description. + +**Prerequisites:** + +- **Gemini CLI** 0.36 or newer for subagents; 0.52.0 is what this is verified against +- The Claude Code [hook prerequisites](#prerequisites) — **Dart SDK** and **jq** — apply unchanged + +[agent_skills_link]: https://agentskills.io/specification +[gemini_cli_link]: https://github.com/google-gemini/gemini-cli [marketplace_link]: https://github.com/VeryGoodOpenSource/very-good-claude-code-marketplace [claude_code_link]: https://claude.ai/code [vgv_link]: https://verygood.ventures diff --git a/config/cspell.json b/config/cspell.json index 876321b..504ec2a 100644 --- a/config/cspell.json +++ b/config/cspell.json @@ -4,6 +4,7 @@ "activatable", "adversarial", "agentskills", + "Anson", "antipattern", "Automator", "Bidirectionality", @@ -45,6 +46,7 @@ "Mundo", "opencode", "pasteable", + "pipefail", "prefs", "promptfoo", "promptfooconfig", diff --git a/hooks/scripts/allow-readonly-git.sh b/hooks/scripts/allow-readonly-git.sh index 33811b7..d376eda 100755 --- a/hooks/scripts/allow-readonly-git.sh +++ b/hooks/scripts/allow-readonly-git.sh @@ -3,8 +3,12 @@ # Allows only `git diff` and `git status`. Denies everything else (file writes, # git checkout/apply, redirections, compound-command bypass). # -# Uses the shared deny() helper (JSON permissionDecision) for consistency with -# the other PreToolUse Bash hook (block-cli-workarounds.sh). +# Uses the shared deny() helper for consistency with the other PreToolUse Bash +# hook (block-cli-workarounds.sh). +# +# Claude Code only. Gemini CLI has no agent-scoped hooks, so the Gemini port of +# the reviewer (.gemini/agents/flutter-reviewer.md) enforces the same read-only +# contract by leaving run_shell_command out of its tool allowlist entirely. # Skip gracefully if jq is unavailable, matching the repo convention. if ! command -v jq &>/dev/null; then @@ -17,6 +21,7 @@ source "$SCRIPT_DIR/vgv-cli-common.sh" DENY_REASON="flutter-reviewer is read-only: only 'git diff' and 'git status' are allowed." INPUT=$(cat) +HOOK_EVENT_NAME=$(read_hook_event "$INPUT") COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') if [ -z "$COMMAND" ]; then diff --git a/hooks/scripts/allow-readonly-git_test.sh b/hooks/scripts/allow-readonly-git_test.sh index 097ea1a..dc9cd09 100755 --- a/hooks/scripts/allow-readonly-git_test.sh +++ b/hooks/scripts/allow-readonly-git_test.sh @@ -6,6 +6,11 @@ # The hook reads a JSON payload from stdin containing tool_input.command, # then prints a deny JSON on stdout if denied, or exits silently if allowed. # We check stdout for the deny marker to determine the result. +# +# The same case list runs twice, once per harness. Claude Code fires the hook as +# "PreToolUse" and reads hookSpecificOutput.permissionDecision; Gemini CLI fires +# the same hook as "BeforeTool" and reads a top-level decision field. The verdict +# must be identical either way. set -uo pipefail @@ -15,13 +20,23 @@ HOOK="$SCRIPT_DIR/allow-readonly-git.sh" PASSED=0 FAILED=0 -run_hook() { +# Event name for the harness currently under test. +HOOK_EVENT="PreToolUse" + +hook_output() { local cmd="$1" local payload - payload=$(jq -n --arg c "$cmd" '{"tool_input":{"command":$c}}') + payload=$(jq -n --arg c "$cmd" --arg e "$HOOK_EVENT" \ + '{"hook_event_name":$e,"tool_input":{"command":$c}}') + echo "$payload" | bash "$HOOK" 2>/dev/null || true +} + +run_hook() { local output - output=$(echo "$payload" | bash "$HOOK" 2>/dev/null) || true - if echo "$output" | grep -q '"permissionDecision"'; then + output=$(hook_output "$1") + if echo "$output" | + jq -e '.hookSpecificOutput.permissionDecision == "deny" or .decision == "deny"' \ + >/dev/null 2>&1; then echo "blocked" else echo "allowed" @@ -54,30 +69,80 @@ assert_allowed() { fi } -echo "=== allow-readonly-git tests ===" +# Assert the deny payload uses the response shape the given harness reads. +assert_deny_shape() { + local cmd="$1" + local filter="$2" + local label="$3" + local output + output=$(hook_output "$cmd") + if echo "$output" | jq -e "$filter" >/dev/null 2>&1; then + printf " \033[32mPASS\033[0m %s\n" "$label" + PASSED=$((PASSED + 1)) + else + printf " \033[31mFAIL\033[0m %s (got: %s)\n" "$label" "$(echo "$output" | tr -d '\n')" + FAILED=$((FAILED + 1)) + fi +} + +run_cases() { + echo "" + echo "--- Should be ALLOWED ---" + assert_allowed "git diff" + assert_allowed "git status" + assert_allowed "git status -s" + assert_allowed "git diff --stat" + assert_allowed "git diff main...HEAD" + assert_allowed " git diff HEAD~1" + + echo "" + echo "--- Should be BLOCKED ---" + assert_blocked "git checkout ." + assert_blocked "git apply patch.diff" + assert_blocked "git commit -m wip" + assert_blocked "git diff > out.txt" + assert_blocked "git status; rm -rf x" + assert_blocked "git diff && rm x" + assert_blocked "git diff | tee out.txt" + assert_blocked 'git diff $(rm x)' + assert_blocked "rm -rf /" + assert_blocked "sed -i s/a/b/ file" + assert_blocked "echo hi > file" + assert_blocked "diff a b" +} + +echo "=== allow-readonly-git tests: Claude Code (PreToolUse) ===" +HOOK_EVENT="PreToolUse" +run_cases + echo "" -echo "--- Should be ALLOWED ---" -assert_allowed "git diff" -assert_allowed "git status" -assert_allowed "git status -s" -assert_allowed "git diff --stat" -assert_allowed "git diff main...HEAD" -assert_allowed " git diff HEAD~1" +echo "=== allow-readonly-git tests: Gemini CLI (BeforeTool) ===" +HOOK_EVENT="BeforeTool" +run_cases echo "" -echo "--- Should be BLOCKED ---" -assert_blocked "git checkout ." -assert_blocked "git apply patch.diff" -assert_blocked "git commit -m wip" -assert_blocked "git diff > out.txt" -assert_blocked "git status; rm -rf x" -assert_blocked "git diff && rm x" -assert_blocked "git diff | tee out.txt" -assert_blocked 'git diff $(rm x)' -assert_blocked "rm -rf /" -assert_blocked "sed -i s/a/b/ file" -assert_blocked "echo hi > file" -assert_blocked "diff a b" +echo "--- Response shape per harness ---" +HOOK_EVENT="PreToolUse" +assert_deny_shape "rm -rf /" \ + '.hookSpecificOutput.permissionDecision == "deny"' \ + "PreToolUse deny uses hookSpecificOutput.permissionDecision" +HOOK_EVENT="BeforeTool" +assert_deny_shape "rm -rf /" \ + '.decision == "deny" and (.reason | length > 0)' \ + "BeforeTool deny uses top-level decision/reason" + +# A payload with no hook_event_name (an older Claude Code build) must still get +# the Claude Code response shape rather than nothing at all. +echo "" +echo "--- Legacy payload without hook_event_name ---" +legacy_output=$(jq -n '{"tool_input":{"command":"rm -rf /"}}' | bash "$HOOK" 2>/dev/null || true) +if echo "$legacy_output" | jq -e '.hookSpecificOutput.permissionDecision == "deny"' >/dev/null 2>&1; then + printf " \033[32mPASS\033[0m defaults to the Claude Code deny shape\n" + PASSED=$((PASSED + 1)) +else + printf " \033[31mFAIL\033[0m expected the Claude Code deny shape\n" + FAILED=$((FAILED + 1)) +fi echo "" echo "=== Results: $PASSED passed, $FAILED failed ===" diff --git a/hooks/scripts/block-cli-workarounds.sh b/hooks/scripts/block-cli-workarounds.sh index b8b14fe..80f2df6 100644 --- a/hooks/scripts/block-cli-workarounds.sh +++ b/hooks/scripts/block-cli-workarounds.sh @@ -12,6 +12,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/vgv-cli-common.sh" INPUT=$(cat) +HOOK_EVENT_NAME=$(read_hook_event "$INPUT") +# Claude Code's Bash tool and Gemini CLI's run_shell_command both carry the +# command under tool_input.command. COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') if [ -z "$COMMAND" ]; then diff --git a/hooks/scripts/block-cli-workarounds_test.sh b/hooks/scripts/block-cli-workarounds_test.sh index 5bb03f0..e2b7230 100755 --- a/hooks/scripts/block-cli-workarounds_test.sh +++ b/hooks/scripts/block-cli-workarounds_test.sh @@ -6,6 +6,12 @@ # The hook reads a JSON payload from stdin containing tool_input.command, # then exits 0 with a deny JSON on stdout if blocked, or exits 0 silently # if allowed. We check stdout for the deny marker to determine the result. +# +# The same case list runs twice, once per harness. Claude Code fires the hook as +# "PreToolUse" against its Bash tool and reads +# hookSpecificOutput.permissionDecision; Gemini CLI fires it as "BeforeTool" +# against run_shell_command and reads a top-level decision field. Both tools +# carry the command under tool_input.command, so the verdict must be identical. set -euo pipefail @@ -15,16 +21,26 @@ HOOK="$SCRIPT_DIR/block-cli-workarounds.sh" PASSED=0 FAILED=0 +# Event name for the harness currently under test. +HOOK_EVENT="PreToolUse" + +hook_output() { + local cmd="$1" + local payload + payload=$(jq -n --arg c "$cmd" --arg e "$HOOK_EVENT" \ + '{"hook_event_name":$e,"tool_input":{"command":$c}}') + echo "$payload" | bash "$HOOK" 2>/dev/null || true +} + # Run hook with a command and check if it was blocked or allowed. # Usage: run_hook "command string" # Returns: "blocked" or "allowed" run_hook() { - local cmd="$1" - local payload - payload=$(jq -n --arg c "$cmd" '{"tool_input":{"command":$c}}') local output - output=$(echo "$payload" | bash "$HOOK" 2>/dev/null) || true - if echo "$output" | grep -q '"permissionDecision"'; then + output=$(hook_output "$1") + if echo "$output" | + jq -e '.hookSpecificOutput.permissionDecision == "deny" or .decision == "deny"' \ + >/dev/null 2>&1; then echo "blocked" else echo "allowed" @@ -57,37 +73,87 @@ assert_allowed() { fi } -echo "=== block-cli-workarounds tests ===" +# Assert the deny payload uses the response shape the given harness reads. +assert_deny_shape() { + local cmd="$1" + local filter="$2" + local label="$3" + local output + output=$(hook_output "$cmd") + if echo "$output" | jq -e "$filter" >/dev/null 2>&1; then + printf " \033[32mPASS\033[0m %s\n" "$label" + PASSED=$((PASSED + 1)) + else + printf " \033[31mFAIL\033[0m %s (got: %s)\n" "$label" "$(echo "$output" | tr -d '\n')" + FAILED=$((FAILED + 1)) + fi +} + +run_cases() { + echo "" + echo "--- Should be BLOCKED ---" + assert_blocked "dart test" + assert_blocked "flutter test" + assert_blocked "dart test test/routing/foo_test.dart" + assert_blocked "flutter test --coverage" + assert_blocked "dart create my_app" + assert_blocked "flutter create my_app" + assert_blocked "very_good create flutter_app --project-name my_app" + assert_blocked "very_good test --coverage --min-coverage 100" + assert_blocked "very_good packages check licenses" + assert_blocked "cd /path && dart test" + assert_blocked "ENV=1 && flutter test --coverage" + + echo "" + echo "--- Should be ALLOWED ---" + assert_allowed "dart analyze lib/foo.dart" + assert_allowed "dart format lib/foo.dart" + assert_allowed "dart pub get" + assert_allowed "dart fix --apply" + assert_allowed "flutter pub get" + assert_allowed "flutter analyze" + assert_allowed "git add lib/router.dart test/router_test.dart" + assert_allowed "dart analyze lib/foo.dart test/bar_test.dart" + assert_allowed "git commit -m 'fix dart test hook'" + assert_allowed "echo 'flutter create is blocked'" + assert_allowed "gh pr create --body 'use dart test instead'" + assert_allowed "git log --grep='dart test'" + assert_allowed "ls" + assert_allowed "pwd" +} + +echo "=== block-cli-workarounds tests: Claude Code (PreToolUse / Bash) ===" +HOOK_EVENT="PreToolUse" +run_cases + echo "" -echo "--- Should be BLOCKED ---" -assert_blocked "dart test" -assert_blocked "flutter test" -assert_blocked "dart test test/routing/foo_test.dart" -assert_blocked "flutter test --coverage" -assert_blocked "dart create my_app" -assert_blocked "flutter create my_app" -assert_blocked "very_good create flutter_app --project-name my_app" -assert_blocked "very_good test --coverage --min-coverage 100" -assert_blocked "very_good packages check licenses" -assert_blocked "cd /path && dart test" -assert_blocked "ENV=1 && flutter test --coverage" +echo "=== block-cli-workarounds tests: Gemini CLI (BeforeTool / run_shell_command) ===" +HOOK_EVENT="BeforeTool" +run_cases echo "" -echo "--- Should be ALLOWED ---" -assert_allowed "dart analyze lib/foo.dart" -assert_allowed "dart format lib/foo.dart" -assert_allowed "dart pub get" -assert_allowed "dart fix --apply" -assert_allowed "flutter pub get" -assert_allowed "flutter analyze" -assert_allowed "git add lib/router.dart test/router_test.dart" -assert_allowed "dart analyze lib/foo.dart test/bar_test.dart" -assert_allowed "git commit -m 'fix dart test hook'" -assert_allowed "echo 'flutter create is blocked'" -assert_allowed "gh pr create --body 'use dart test instead'" -assert_allowed "git log --grep='dart test'" -assert_allowed "ls" -assert_allowed "pwd" +echo "--- Response shape per harness ---" +HOOK_EVENT="PreToolUse" +assert_deny_shape "dart test" \ + '.hookSpecificOutput.permissionDecision == "deny"' \ + "PreToolUse deny uses hookSpecificOutput.permissionDecision" +HOOK_EVENT="BeforeTool" +assert_deny_shape "dart test" \ + '.decision == "deny" and (.reason | length > 0)' \ + "BeforeTool deny uses top-level decision/reason" + +# A payload with no hook_event_name (an older Claude Code build) must still get +# the Claude Code response shape rather than nothing at all. +echo "" +echo "--- Legacy payload without hook_event_name ---" +legacy_output=$(jq -n '{"tool_input":{"command":"dart test"}}' | bash "$HOOK" 2>/dev/null || true) +if echo "$legacy_output" | jq -e '.hookSpecificOutput.permissionDecision == "deny"' >/dev/null 2>&1; then + printf " \033[32mPASS\033[0m defaults to the Claude Code deny shape\n" + PASSED=$((PASSED + 1)) +else + printf " \033[31mFAIL\033[0m expected the Claude Code deny shape\n" + FAILED=$((FAILED + 1)) +fi echo "" echo "=== Results: $PASSED passed, $FAILED failed ===" diff --git a/hooks/scripts/check-vgv-cli.sh b/hooks/scripts/check-vgv-cli.sh index 979665d..fed43cd 100644 --- a/hooks/scripts/check-vgv-cli.sh +++ b/hooks/scripts/check-vgv-cli.sh @@ -12,6 +12,11 @@ fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/vgv-cli-common.sh" +# Drain stdin so the payload's event name picks the right response shape +# (Claude Code "PreToolUse" vs Gemini CLI "BeforeTool"). +INPUT=$(cat 2>/dev/null) +HOOK_EVENT_NAME=$(read_hook_event "$INPUT") + cli_status=$(check_vgv_cli) case "$cli_status" in not_installed) diff --git a/hooks/scripts/gemini-config_test.sh b/hooks/scripts/gemini-config_test.sh new file mode 100755 index 0000000..f9becb1 --- /dev/null +++ b/hooks/scripts/gemini-config_test.sh @@ -0,0 +1,212 @@ +#!/bin/bash +# Tests for the Gemini CLI port of the enforcement layer (.gemini/). +# +# Usage: bash hooks/scripts/gemini-config_test.sh +# +# Gemini CLI fails silently on every mistake this file guards against: an +# un-migrated Claude Code event name is skipped with a one-line warning, a +# timeout copied over in seconds becomes a few-millisecond timeout, a Claude +# tool matcher never matches a Gemini tool, and an agent whose frontmatter +# carries an unrecognized key is dropped from the registry. None of that breaks +# a build on its own, so it is asserted here. +# +# Static only: it reads the committed files and needs jq, not Gemini CLI. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SETTINGS="$REPO_ROOT/.gemini/settings.json" +AGENT="$REPO_ROOT/.gemini/agents/flutter-reviewer.md" +MCP_JSON="$REPO_ROOT/.mcp.json" + +PASSED=0 +FAILED=0 + +pass() { + printf " \033[32mPASS\033[0m %s\n" "$1" + PASSED=$((PASSED + 1)) +} + +fail() { + printf " \033[31mFAIL\033[0m %s\n" "$1" + FAILED=$((FAILED + 1)) +} + +check() { + # check "