#!/usr/bin/env bash # ask-gemini.sh — Claude -> Google Gemini CLI router (independent second model). # # Sibling of ask-grok.sh. Routes a task to the official Google Gemini CLI # (`gemini`, npm global) for an independent, different-vendor second opinion, # verification, or a Gemini code review. Headless, safe-by-default, JSON-parsed. # # Auth is Google login (OAuth) — NO API key. Creds: ~/.gemini/oauth_creds.json. # If a call fails with an auth error, run `gemini` interactively once and pick # "Login with Google". # # Output contract (VERIFIED on GURU-5070, gemini 0.45.2): # - Prefer JSON: `gemini -p ... -o json` -> {session_id, response, stats}. # The answer text is `.response`. stdout may carry two cosmetic warning lines # ("True color..." / "Ripgrep is not available...") before the JSON; we extract # the object starting at the FIRST '{' to ignore them. stderr (429 backoff, # warnings) is captured SEPARATELY and never fed to the JSON parser. # - `--skip-trust` is REQUIRED headless (the CWD isn't a trusted folder). # - stdin is always closed (" # one-shot answer # ask-gemini.sh text --prompt-file # long content # ask-gemini.sh verify "" # adversarial check # ask-gemini.sh verify --prompt-file # ask-gemini.sh review [instructions] # gemini reads + reviews one file # ask-gemini.sh review-files [-i "instr"] [f2 ...] # review a SET of files together # ask-gemini.sh review-diff [-C ] [-i "instr"] [-- ] # ask-gemini.sh image-analyze ["question"] # vision: read_file image + describe (PRO model) # ask-gemini.sh search "" # Google-grounded live web search + sources # ask-gemini.sh raw # escape hatch # # Exit: 0 ok, 1 no result, 2 usage, 3 not installed here, 127 gemini/python not found. set -uo pipefail SELF="ask-gemini" PY="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3 2>/dev/null || true)" [ -z "$PY" ] && { echo "[$SELF] python (py/python/python3) required for JSON parsing" >&2; exit 127; } # --- path conversion: native-Windows path for the gemini args (no-op off Windows) --- # gemini is a native Windows binary (npm shim -> node.exe); Git Bash hands it POSIX # paths (/tmp, /c/.., /d/..) it cannot resolve. cygpath -w converts to C:\... on # MSYS/Cygwin; on Linux/macOS it passes through unchanged. Explicit conversion # removes reliance on MSYS auto-conversion (which breaks on spaces/edge cases). if command -v cygpath >/dev/null 2>&1; then winpath() { cygpath -w -- "$1" 2>/dev/null || printf '%s' "$1"; } else winpath() { printf '%s' "$1"; } fi # --- identity.json (per-machine, gitignored) declares whether gemini is installed here --- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)" IDFILE="" [ -n "${CLAUDETOOLS_ROOT:-}" ] && [ -f "$CLAUDETOOLS_ROOT/.claude/identity.json" ] && IDFILE="$CLAUDETOOLS_ROOT/.claude/identity.json" [ -z "$IDFILE" ] && IDFILE="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd)/identity.json" idgem() { # read field $1 from identity.json .gemini (empty if absent) [ -f "$IDFILE" ] || { echo ""; return; } "$PY" -c "import json,sys try: g=(json.load(sys.stdin).get('gemini') or {}); v=g.get('$1','') print('' if v is None else (str(v).lower() if isinstance(v,bool) else v)) except Exception: print('')" < "$IDFILE" } # If identity explicitly says gemini is NOT installed here, fail fast with guidance. if [ "$(idgem installed)" = "false" ]; then echo "[$SELF] gemini is not installed on this machine (identity.json gemini.installed=false)." >&2 echo "[$SELF] Gemini runs only on the fleet host. Route this request there, or install the gemini CLI (npm i -g @google/gemini-cli) + set identity.json gemini.installed=true." >&2 exit 3 fi # --- locate the gemini binary: GEMINI env > identity.json gemini.binary > auto-locate --- # An explicit GEMINI= override that isn't runnable is a user error -> fail clearly up front # (covers absolute paths AND a bare name resolvable on PATH, e.g. GEMINI=gemini). GEMINI="${GEMINI:-}" if [ -n "$GEMINI" ] && [ ! -x "$GEMINI" ] && ! command -v "$GEMINI" >/dev/null 2>&1; then echo "[$SELF] GEMINI='$GEMINI' is not an executable gemini binary." >&2; exit 127 fi cand="$(idgem binary)" [ -z "$GEMINI" ] && [ -n "$cand" ] && [ -x "$cand" ] && GEMINI="$cand" if [ -z "$GEMINI" ]; then if command -v gemini >/dev/null 2>&1; then GEMINI="$(command -v gemini)"; else for c in "${APPDATA:-}/npm/gemini" "/c/Users/${USERNAME:-${USER:-x}}/AppData/Roaming/npm/gemini" \ "$HOME/AppData/Roaming/npm/gemini" "/usr/local/bin/gemini" "$HOME/.npm-global/bin/gemini"; do [ -n "$c" ] && [ -x "$c" ] && { GEMINI="$c"; break; } done fi fi [ -z "$GEMINI" ] && { echo "[$SELF] gemini CLI not found (set identity.json gemini.binary, GEMINI=, or install: npm i -g @google/gemini-cli)" >&2; exit 127; } # Model: default routing for text; a strong pinned model for verify/review. # gemini-3.1-pro-preview verified available on this account (2026-06-05); overridable. STRONG_MODEL="${GEMINI_MODEL:-gemini-3.1-pro-preview}" MODE="${1:-}"; shift 2>/dev/null || true [ -z "$MODE" ] && { echo "usage: $SELF {text|verify|review|review-files|review-diff|image-analyze|search|raw} ..." >&2; exit 2; } TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT PF="$TMP/prompt.txt"; OUT="$TMP/out.txt"; ERR="$TMP/err.txt" REPO_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)}" # Functional-error logger (skill name "agy"); soft-fails, never breaks the caller. _logerr() { bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "agy" "$@" >/dev/null 2>&1 || true; } # gtimeout on macOS (brew coreutils), timeout elsewhere. TIMEOUT_CMD="timeout" if [[ "${OSTYPE:-}" == "darwin"* ]]; then TIMEOUT_CMD="$(command -v gtimeout 2>/dev/null || echo timeout)" fi # run gemini headless reading the prompt file. $1=timeout secs; rest=extra flags. # stdout -> $OUT, stderr -> $ERR (kept separate so warning/429 noise never reaches # the JSON parser). Never fail the script on gemini's exit code; we judge by output. # Records the invocation so emit_or_fail can replay it once on a transient empty turn. LAST_RUN=() run_gemini() { local to="$1"; shift LAST_RUN=("$to" "$@") "$TIMEOUT_CMD" "$to" "$GEMINI" -p "$(cat "$PF")" -o json --skip-trust "$@" \ >"$OUT" 2>"$ERR" ', 'uem_image_0_0_png}'). Off by default so text/verify/review/search are # byte-exact. We only remove a junk run that ends in a stream delimiter (} > :) # or a lone leading punctuation char, immediately before the first real sentence. if os.environ.get('AGY_CLEAN') == '1' and r: # The pro-preview tool loop sometimes prepends a numbered/markdown reasoning # block before the actual answer. If a clear answer pivot follows such a # preamble, keep from the pivot onward (the user-facing answer). if re.search(r'(?im)^\s*\d+[.)]\s', r) or 'thought' in r[:60].lower(): pivs=list(re.finditer(r'(?i)(Based on the image\b|\*\*Answer:?\*\*|The image (?:contains|shows|displays)\b)', r)) if pivs: r=r[pivs[-1].start():] m=re.match(r'^[^\n]{0,40}?(?:\.png\)|\.jpe?g\)|[}>:)])\s*([\"A-Z].*)$', r, re.S) if m and m.group(1): r=m.group(1) else: # a short leading junk run (ASCII punctuation/digits or non-Latin stream # tokens) before a capitalized/quoted sentence start. Bounded length so we # never eat a real lowercase sentence or real prose. m=re.match(r'^(?:[^A-Za-z\"]|[^\x00-\x7f]){1,8}([A-Z\"].*)$', r, re.S) if m and m.group(1): r=m.group(1) r=r.strip() print(r)" < "$OUT"; } # detect a GENUINE auth failure in stderr (precise remediation hint). Tightened 2026-06-17 - # the old broad regex (bare login|credential|authenticat|oauth|401) matched benign mid-run # token-refresh lines and false-flagged working sessions as auth failures. auth_failed() { grep -qiE 'invalid_grant|unauthorized|not authenticated|authentication failed|re-?authenticat|please (log|sign).?in|login with google|token (has )?expired|no (valid )?credentials' "$ERR" 2>/dev/null; } # detect a quota / rate-capacity exhaustion (the pinned strong model can be capped mid-session) quota_exhausted() { grep -qiE 'exhausted your capacity|quota|resource[_ ]?exhausted|rate limit|too many requests|429' "$ERR" 2>/dev/null; } emit_or_fail() { # print .response; gemini intermittently returns an empty turn, so retry a few # times with backoff before giving up (single retry was insufficient - 2 empties # in a row caused spurious failures during live research, 2026-06-17). # Do ALL retries first; only classify the failure (auth vs generic) AFTER exhausting them. # (Checking auth_failed INSIDE the loop caused false aborts: a benign mid-run credential-refresh # line in stderr matched the auth regex and killed the retries even though auth was fine. 2026-06-17.) local txt tries=0 max="${AGY_MAX_TRIES:-3}" txt="$(gresponse)" while [ -z "$txt" ] && [ "$tries" -lt $((max-1)) ] && [ ${#LAST_RUN[@]} -gt 0 ]; do tries=$((tries+1)) echo "[$SELF] empty response - retry $tries/$((max-1)) (backoff ${tries}x3s)..." >&2 sleep $((tries*3)) # 3s, 6s backoff (covers transient empties / 429s / token refresh) run_gemini "${LAST_RUN[@]}" txt="$(gresponse)" done if [ -n "$txt" ]; then printf '%s\n' "$txt"; return 0; fi # Quota fallback: if the pinned strong model is capacity/quota-capped, retry ONCE on the default # (lighter) model by stripping -m from the last invocation - the default model has a separate quota. if quota_exhausted && [ ${#LAST_RUN[@]} -gt 0 ]; then echo "[$SELF] '$STRONG_MODEL' quota exhausted - retrying once on the default (lighter) model..." >&2 local nr=() a skip=0 for a in "${LAST_RUN[@]}"; do if [ "$skip" = 1 ]; then skip=0; continue; fi if [ "$a" = "-m" ]; then skip=1; continue; fi nr+=("$a") done run_gemini "${nr[@]}" txt="$(gresponse)" if [ -n "$txt" ]; then printf '%s\n' "$txt"; return 0; fi fi if auth_failed; then echo "[$SELF] Gemini auth error - run 'gemini' interactively and choose 'Login with Google', then retry." >&2 _logerr "gemini auth/login failure" --context "mode=$MODE" else echo "[$SELF] no response from gemini after $max attempts. stderr tail:" >&2 tail -3 "$ERR" >&2 2>/dev/null || true _logerr "gemini returned no response (empty after $max attempts)" --context "mode=$MODE err=$(tail -1 "$ERR" 2>/dev/null | tr -d '\n' | cut -c1-80)" fi exit 1 } # Copy target files into an included temp workspace dir so gemini's read_file can # reach them regardless of .gitignore / workspace sandbox. Echoes the included dir. INCLUDE_DIR="$TMP/inbox" prep_includes() { mkdir -p "$INCLUDE_DIR"; } case "$MODE" in text|verify) SRC="" if [ "${1:-}" = "--prompt-file" ]; then [ -f "${2:-}" ] || { echo "[$SELF] prompt file not found: ${2:-}" >&2; exit 2; } SRC="$(cat "$2")" else SRC="${1:-}" fi [ -z "$SRC" ] && { echo "usage: $SELF $MODE \"\" | $SELF $MODE --prompt-file " >&2; exit 2; } if [ "$MODE" = "verify" ]; then printf 'You are an adversarial reviewer giving an independent second opinion. Evaluate the following claim/finding/document: try hard to find any way it is WRONG, incomplete, unsupported, or overstated. Then give a clear VERDICT (e.g. correct / partly correct / incorrect) plus specific justification. Answer in text only; do not use any tools.\n\nContent:\n%s' "$SRC" > "$PF" run_gemini 180 -m "$STRONG_MODEL" else printf 'Answer the following directly in text. Do not use any tools.\n\n%s' "$SRC" > "$PF" run_gemini 180 fi emit_or_fail ;; review|file) [ -z "${1:-}" ] && { echo "usage: $SELF review [instructions]" >&2; exit 2; } target="$1" instr="${2:-Give an independent, critical review of this file: accuracy, gaps/omissions, bugs, and concrete improvements. Be specific.}" # GOTCHA: a relative path resolves against ONLY CWD or $REPO_ROOT ($CLAUDETOOLS_ROOT) -- # NOT a submodule/subdir. "server/src/x.rs" relative to a submodule fails ("file not found") # unless CWD is that submodule. Pass ABSOLUTE paths for submodule/subtree files. if [ -f "$target" ]; then resolved="$target" elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target" else echo "[$SELF] file not found: $target" >&2; exit 2; fi prep_includes base="$(basename "$resolved")" cp -f "$resolved" "$INCLUDE_DIR/$base" tgt_win="$(winpath "$INCLUDE_DIR/$base")" inc_win="$(winpath "$INCLUDE_DIR")" printf 'Use your read_file tool to read the file at this absolute path, then perform the task and stop. Do not modify anything.\nPath: %s\n\nTask: %s' "$tgt_win" "$instr" > "$PF" run_gemini 240 -m "$STRONG_MODEL" --approval-mode plan --include-directories "$inc_win" emit_or_fail ;; review-files) instr='Independently review these files together as a unit: correctness/bugs, gaps, cross-file consistency, and concrete improvements. Be specific and cite file:line.' files=() while [ $# -gt 0 ]; do case "$1" in -i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;; *) files+=("$1"); shift ;; esac done [ ${#files[@]} -eq 0 ] && { echo "usage: $SELF review-files [-i \"instructions\"] [file ...]" >&2; exit 2; } prep_includes list="" declare -A seen=() # GOTCHA: each relative path resolves against ONLY CWD or $REPO_ROOT ($CLAUDETOOLS_ROOT) -- # NOT a submodule/subdir. Paths relative to a submodule fail unless CWD is that submodule. # Pass ABSOLUTE paths for submodule/subtree files (e.g. build the list with `find "$(pwd)/..."`). for f in "${files[@]}"; do if [ -f "$f" ]; then r="$f" elif [ -f "$REPO_ROOT/$f" ]; then r="$REPO_ROOT/$f" else echo "[$SELF] file not found: $f" >&2; exit 2; fi base="$(basename "$r")" # de-collide identical basenames from different dirs if [ -n "${seen[$base]:-}" ]; then n=1; while [ -e "$INCLUDE_DIR/${n}_${base}" ]; do n=$((n+1)); done; base="${n}_${base}" fi seen[$base]=1 cp -f "$r" "$INCLUDE_DIR/$base" list+="- $(winpath "$INCLUDE_DIR/$base") " done inc_win="$(winpath "$INCLUDE_DIR")" printf 'Use your read_file tool to read EACH of these files (absolute paths), then perform the task across ALL of them and stop. Do not modify anything.\n\nFiles:\n%s\nTask: %s' "$list" "$instr" > "$PF" run_gemini 300 -m "$STRONG_MODEL" --approval-mode plan --include-directories "$inc_win" emit_or_fail ;; review-diff) gdir="$REPO_ROOT" instr='Review this git diff: correctness/bugs introduced, regressions, missing edge cases, and concrete fixes. Focus on the CHANGES. Be specific and cite file:line.' ref=""; pathspec=() while [ $# -gt 0 ]; do case "$1" in -C|--dir) gdir="${2:-}"; shift 2 2>/dev/null || shift ;; -i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;; --) shift; while [ $# -gt 0 ]; do pathspec+=("$1"); shift; done ;; *) if [ -z "$ref" ]; then ref="$1"; else pathspec+=("$1"); fi; shift ;; esac done [ -z "$ref" ] && { echo "usage: $SELF review-diff [-C ] [-i \"instr\"] [-- ]" >&2; exit 2; } [ -d "$gdir" ] || { [ -d "$REPO_ROOT/$gdir" ] && gdir="$REPO_ROOT/$gdir"; } git -C "$gdir" rev-parse --git-dir >/dev/null 2>&1 || { echo "[$SELF] not a git repo: $gdir" >&2; exit 2; } if [ ${#pathspec[@]} -gt 0 ]; then git -C "$gdir" diff "$ref" -- "${pathspec[@]}" > "$TMP/diff.txt" 2>"$TMP/differr.txt" else git -C "$gdir" diff "$ref" > "$TMP/diff.txt" 2>"$TMP/differr.txt" fi [ -s "$TMP/diff.txt" ] || { echo "[$SELF] empty/failed diff for '$ref' in $gdir: $(head -1 "$TMP/differr.txt" 2>/dev/null)" >&2; exit 1; } gdir_win="$(winpath "$gdir")" { printf 'Review the following unified git diff. %s\nYou may use your read_file tool on any changed file for full context (paths in the diff are relative to %s; strip the a/ b/ prefixes). Do not modify anything.\n\n=== BEGIN DIFF ===\n' "$instr" "$gdir_win"; cat "$TMP/diff.txt"; printf '\n=== END DIFF ===\n'; } > "$PF" run_gemini 300 -m "$STRONG_MODEL" --approval-mode plan --include-directories "$gdir_win" emit_or_fail ;; image-analyze|image|vision) # Independent second-model VISION. The default flash-lite router hallucinates # image content, so we PIN the pro vision model (STRONG_MODEL) and run with # yolo approval so read_file can execute. The image is copied into an included # temp dir (like the review modes) and handed to Gemini by absolute winpath. [ -z "${1:-}" ] && { echo "usage: $SELF image-analyze [\"question\"]" >&2; exit 2; } target="$1" question="${2:-Describe exactly what is in this image.}" if [ -f "$target" ]; then resolved="$target" elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target" else echo "[$SELF] image not found: $target" >&2; exit 2; fi prep_includes base="$(basename "$resolved")" cp -f "$resolved" "$INCLUDE_DIR/$base" img_win="$(winpath "$INCLUDE_DIR/$base")" inc_win="$(winpath "$INCLUDE_DIR")" # Image path goes in via %s (never as a printf format string). printf 'Use your read_file tool to read the image at this absolute path, then describe exactly what you see. Report only what is actually present in the image; do not guess or invent content. Then stop. Do not modify anything.\nImage path: %s\n\nQuestion: %s' "$img_win" "$question" > "$PF" run_gemini 240 -m "$STRONG_MODEL" --approval-mode yolo --include-directories "$inc_win" AGY_CLEAN=1 emit_or_fail ;; search|websearch) # Google-grounded LIVE web search (mirrors grok xsearch). Gemini's # google_web_search tool works on OAuth; run with yolo so the tool can fire. # Query goes via the prompt file so long queries don't hit shell-quote limits. SRC="" if [ "${1:-}" = "--prompt-file" ]; then [ -f "${2:-}" ] || { echo "[$SELF] prompt file not found: ${2:-}" >&2; exit 2; } SRC="$(cat "$2")" else SRC="${1:-}" fi [ -z "$SRC" ] && { echo "usage: $SELF search \"\" | $SELF search --prompt-file " >&2; exit 2; } printf 'Use your google_web_search tool to find current, live information answering the following, then stop. Answer concisely and ALWAYS include the source URLs you used (a Sources list of full URLs). Do not fabricate URLs.\n\nQuery: %s' "$SRC" > "$PF" run_gemini 180 -m "$STRONG_MODEL" --approval-mode yolo emit_or_fail ;; raw) "$GEMINI" "$@" ;; *) echo "[$SELF] unknown mode '$MODE' (use text|verify|review|review-files|review-diff|image-analyze|search|raw)" >&2; exit 2 ;; esac