Sibling of the grok skill: routes text/verify/review (+ review-files, review-diff, raw) to the official Google Gemini CLI (gemini, npm global, v0.45.1) for an independent second model. ask-gemini.sh mirrors ask-grok.sh (identity-aware gating, binary auto-locate, cygpath hardening, prompt-file inputs, clean stdout/stderr separation, JSON .response extraction). review modes copy targets into a temp dir + --include-directories to bypass Gemini's gitignore/workspace sandbox. verify/review pinned to gemini-3.1-pro-preview (GEMINI_MODEL overridable). migrate-identity.sh auto-detects gemini and writes a per-machine identity.json gemini block. Auth: Google OAuth (no key). Fleet Gemini host: GURU-5070. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
14 KiB
Bash
273 lines
14 KiB
Bash
#!/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.1):
|
|
# - 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 (</dev/null) so `-p` never hangs waiting on stdin.
|
|
#
|
|
# File reads (review*): Gemini's read_file honors .gitignore AND a workspace
|
|
# sandbox (only files under the workspace/included dirs are readable). To make
|
|
# review robust for ANY file (tracked, gitignored, with spaces), we copy each
|
|
# target into a temp dir and add it to the workspace via --include-directories.
|
|
# review-diff runs with the repo dir included so changed files read in place.
|
|
#
|
|
# Usage:
|
|
# ask-gemini.sh text "<prompt>" # one-shot answer
|
|
# ask-gemini.sh text --prompt-file <path> # long content
|
|
# ask-gemini.sh verify "<claim or finding to refute>" # adversarial check
|
|
# ask-gemini.sh verify --prompt-file <path>
|
|
# ask-gemini.sh review <file> [instructions] # gemini reads + reviews one file
|
|
# ask-gemini.sh review-files [-i "instr"] <f1> [f2 ...] # review a SET of files together
|
|
# ask-gemini.sh review-diff [-C <repo-dir>] [-i "instr"] <gitref> [-- <pathspec>]
|
|
# ask-gemini.sh raw <gemini args...> # 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|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)}"
|
|
|
|
# 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" </dev/null || true
|
|
}
|
|
|
|
# extract .response from the JSON object starting at the first '{' in $OUT.
|
|
# Parsed via stdin so Windows python never resolves a git-bash (/c/...) path.
|
|
gresponse() { "$PY" -c "import json,sys
|
|
raw=sys.stdin.read()
|
|
i=raw.find('{')
|
|
if i < 0:
|
|
print(''); sys.exit(0)
|
|
try:
|
|
print(json.loads(raw[i:]).get('response','') or '')
|
|
except Exception:
|
|
print('')" < "$OUT"; }
|
|
|
|
# detect an auth failure in stderr (so we can give a precise remediation hint)
|
|
auth_failed() { grep -qiE 'oauth|unauthor|authenticat|login|credential|invalid_grant|401' "$ERR" 2>/dev/null; }
|
|
|
|
emit_or_fail() { # print .response, or retry once on a transient empty turn, else fail
|
|
local txt; txt="$(gresponse)"
|
|
if [ -n "$txt" ]; then printf '%s\n' "$txt"; return 0; fi
|
|
# Auth failures won't be fixed by a retry — report immediately.
|
|
if auth_failed; then
|
|
echo "[$SELF] Gemini auth error — run 'gemini' interactively and choose 'Login with Google', then retry." >&2
|
|
exit 1
|
|
fi
|
|
# Gemini occasionally returns an empty turn (or absorbs a 429 backoff into the
|
|
# timeout). Replay the identical call once before giving up.
|
|
if [ ${#LAST_RUN[@]} -gt 0 ]; then
|
|
echo "[$SELF] empty response — retrying once..." >&2
|
|
run_gemini "${LAST_RUN[@]}"
|
|
txt="$(gresponse)"
|
|
if [ -n "$txt" ]; then printf '%s\n' "$txt"; return 0; fi
|
|
if auth_failed; then
|
|
echo "[$SELF] Gemini auth error — run 'gemini' interactively and choose 'Login with Google', then retry." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
echo "[$SELF] no response from gemini. stderr tail:" >&2
|
|
tail -3 "$ERR" >&2 2>/dev/null || true
|
|
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 \"<prompt>\" | $SELF $MODE --prompt-file <path>" >&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 <file-path> [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.}"
|
|
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> [file ...]" >&2; exit 2; }
|
|
prep_includes
|
|
list=""
|
|
declare -A seen=()
|
|
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 <repo-dir>] [-i \"instr\"] <gitref> [-- <pathspec>]" >&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
|
|
;;
|
|
|
|
raw)
|
|
"$GEMINI" "$@"
|
|
;;
|
|
|
|
*)
|
|
echo "[$SELF] unknown mode '$MODE' (use text|verify|review|review-files|review-diff|raw)" >&2; exit 2 ;;
|
|
esac
|