If a grok read_file-based review (review/review-files/review-diff) returns empty (the 0.2.20-style headless tool-gating regression), retry once with the file(s)/diff embedded inline via the no-tools text path, when content is under 256KB; otherwise emit a clear skip note. Keeps grok-reads-files as the default happy path (works on 0.2.22) and degrades gracefully instead of returning silence. text/verify/raw unchanged; Windows path handling intact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
333 lines
18 KiB
Bash
333 lines
18 KiB
Bash
#!/usr/bin/env bash
|
|
# ask-grok.sh — Claude -> Grok capability router.
|
|
#
|
|
# Routes a task to the Grok CLI (xAI Grok 4.3) for capabilities Claude lacks
|
|
# (image/video generation, live web + X/Twitter data) or for an independent
|
|
# second model (verification, drafts). Headless, safe-by-default, artifact-aware.
|
|
#
|
|
# Auth is Grok's own OIDC (~/.grok/auth.json, grok.com login) — NO API key here.
|
|
# Prompts are ALWAYS passed via --prompt-file (inline args break on shell quoting).
|
|
# Artifacts (image/video) are retrieved by globbing the session dir by sessionId,
|
|
# so they're recovered even when the headless run reports stopReason=Cancelled
|
|
# before echoing the path (a known finalization quirk).
|
|
#
|
|
# Usage:
|
|
# ask-grok.sh text "<prompt>" # text / reasoning / second opinion
|
|
# ask-grok.sh verify "<claim or finding to refute>" # adversarial check (text + --check)
|
|
# ask-grok.sh image "<prompt>" [out.png] # image_gen -> copy artifact to out
|
|
# ask-grok.sh video "<prompt>" <input-image> [out.mp4] # image_to_video on input image
|
|
# ask-grok.sh xsearch "<query>" # live X/Twitter + web search
|
|
# ask-grok.sh review <file> [instructions] # grok read_file's + reviews one file
|
|
# ask-grok.sh review-files [-i "instr"] <f1> [f2 ...] # review a SET of files together
|
|
# ask-grok.sh review-diff [-C <repo-dir>] [-i "instr"] <gitref> [-- <pathspec>] # review a git diff
|
|
# ask-grok.sh raw <grok args...> # escape hatch (passes through)
|
|
#
|
|
# Exit: 0 ok, 1 no result/artifact, 2 usage, 127 grok not found.
|
|
set -uo pipefail
|
|
SELF="ask-grok"
|
|
|
|
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 grok.exe args (no-op off Windows) ---
|
|
# grok.exe is a native Windows binary; Git Bash hands it POSIX paths (/tmp, /c/.., /d/..)
|
|
# that it cannot resolve. cygpath -w converts to C:\... form on MSYS/Cygwin; on Linux/macOS
|
|
# (native grok, already-correct paths) it passes through unchanged. Doing this explicitly
|
|
# removes reliance on MSYS's heuristic 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 grok 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"
|
|
idgrok() { # read field $1 from identity.json .grok (empty if absent)
|
|
[ -f "$IDFILE" ] || { echo ""; return; }
|
|
"$PY" -c "import json,sys
|
|
try:
|
|
g=(json.load(sys.stdin).get('grok') 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 grok is NOT installed here, fail fast with routing guidance.
|
|
if [ "$(idgrok installed)" = "false" ]; then
|
|
echo "[$SELF] grok is not installed on this machine (identity.json grok.installed=false)." >&2
|
|
echo "[$SELF] Grok runs only on the fleet grok host. Route this request there (remote routing not yet wired) or install grok + set identity.json grok.installed=true." >&2
|
|
exit 3
|
|
fi
|
|
|
|
# --- locate the grok binary: GROK env > identity.json grok.binary > auto-locate ---
|
|
GROK="${GROK:-}"
|
|
cand="$(idgrok binary)"
|
|
[ -z "$GROK" ] && [ -n "$cand" ] && [ -x "$cand" ] && GROK="$cand"
|
|
if [ -z "$GROK" ]; then
|
|
if command -v grok >/dev/null 2>&1; then GROK="$(command -v grok)"; else
|
|
for c in "$HOME/.grok/bin/grok.exe" "/c/Users/${USERNAME:-${USER:-x}}/.grok/bin/grok.exe" \
|
|
"$HOME/.grok/bin/grok" "${LOCALAPPDATA:-}/Programs/grok/grok.exe"; do
|
|
[ -x "$c" ] && { GROK="$c"; break; }
|
|
done
|
|
fi
|
|
fi
|
|
[ -z "$GROK" ] && { echo "[$SELF] grok CLI not found (set identity.json grok.binary, GROK=, or install grok)" >&2; exit 127; }
|
|
|
|
MODE="${1:-}"; shift 2>/dev/null || true
|
|
[ -z "$MODE" ] && { echo "usage: $SELF {text|verify|image|video|xsearch|review|review-files|review-diff|raw} ..." >&2; exit 2; }
|
|
|
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
WORK="$TMP/work"; mkdir -p "$WORK"
|
|
PF="$TMP/prompt.txt"; OUT="$TMP/out.json"
|
|
RUN_CWD="$WORK" # grok's working dir; the 'review' mode overrides to the repo so read_file can reach repo files
|
|
REPO_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)}"
|
|
|
|
# run grok headless. $1=timeout secs; rest=extra flags. Reads $PF -> $OUT.
|
|
# Never fails the script on grok's exit code (Cancelled is expected; we read artifacts).
|
|
# Use gtimeout on macOS (from brew coreutils), timeout on Linux/Windows.
|
|
TIMEOUT_CMD="timeout"
|
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
TIMEOUT_CMD="$(command -v gtimeout 2>/dev/null || echo timeout)"
|
|
fi
|
|
|
|
run_grok() {
|
|
local to="$1"; shift
|
|
# Hand grok native-Windows paths (cygpath); MSYS leaves already-Windows paths alone,
|
|
# so conversion is deterministic and space-safe.
|
|
"$TIMEOUT_CMD" "$to" "$GROK" --prompt-file "$(winpath "$PF")" --output-format json \
|
|
--permission-mode dontAsk --no-subagents --no-plan --cwd "$(winpath "$RUN_CWD")" "$@" \
|
|
>"$OUT" 2>"$TMP/err.txt" || true
|
|
}
|
|
|
|
# parse a top-level field from $OUT. Read via stdin so Windows python never has
|
|
# to resolve the git-bash (/c/...) path itself.
|
|
jfield() { "$PY" -c "import json,sys
|
|
try:
|
|
d=json.load(sys.stdin); print(d.get('$1','') or '')
|
|
except Exception:
|
|
print('')" < "$OUT"; }
|
|
|
|
# newest artifact under any session dir for this sessionId: $1=sid $2=images|videos
|
|
find_artifact() {
|
|
ls -t "$HOME/.grok/sessions/"*"/$1/$2/"* 2>/dev/null | head -1
|
|
}
|
|
|
|
# --- self-healing embed fallback for review modes -----------------------------
|
|
# The review/review-files/review-diff modes default to letting grok read the
|
|
# target files/diff ITSELF (read_file tool) — this works on grok >=0.2.22 and
|
|
# avoids stuffing large files into the prompt. But on grok 0.2.20 headless
|
|
# read_file wasn't wired, so those runs came back EMPTY (silent failure). The
|
|
# text/verify modes never had this problem because they EMBED all content inline
|
|
# (no tools). To survive a future regression of that kind, each review mode below
|
|
# retries ONCE with the file/diff contents embedded inline (the no-tools text
|
|
# path) when the grok-reads-files run returns empty — but only when the payload
|
|
# is small enough to safely inline (EMBED_FALLBACK_MAX_BYTES). Over that size we
|
|
# keep the existing behavior (report "no result") rather than blow up the prompt.
|
|
EMBED_FALLBACK_MAX_BYTES=262144 # ~256KB ceiling for inlining content into the prompt
|
|
|
|
# byte size of one or more files, summed; prints an integer (0 if none readable).
|
|
bytes_of_files() {
|
|
local total=0 n
|
|
for f in "$@"; do
|
|
n="$(wc -c < "$f" 2>/dev/null || echo 0)"
|
|
n="${n//[^0-9]/}"; [ -z "$n" ] && n=0
|
|
total=$(( total + n ))
|
|
done
|
|
printf '%s' "$total"
|
|
}
|
|
|
|
# Run grok in the no-tools text path against the already-built $PF, capturing the
|
|
# result into the caller's variable. Mirrors the text-mode invocation (web search
|
|
# off, short turn budget) since everything it needs is already in the prompt.
|
|
# Resets RUN_CWD to a neutral working dir so no tool-reachable cwd is implied.
|
|
embed_fallback_run() {
|
|
RUN_CWD="$WORK"
|
|
run_grok 240 --disable-web-search --max-turns 3
|
|
jfield text
|
|
}
|
|
|
|
case "$MODE" in
|
|
text|verify)
|
|
# content from --prompt-file <path> (good for long docs) or the positional arg
|
|
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; }
|
|
# Prompt-level steering keeps it text-only and (for verify) adversarial.
|
|
# (The --disallowed-tools/--rules flags tripped the CLI; --check adds a slow
|
|
# multi-turn self-check loop — both avoided in favor of prompt steering.)
|
|
if [ "$MODE" = "verify" ]; then
|
|
printf 'Adversarially evaluate the following claim/finding/document: try hard to find any reason it is WRONG, incomplete, or overstated. Then give a verdict plus specific justification. Answer in text only; do not use tools. Content:\n%s' "$SRC" > "$PF"
|
|
else
|
|
printf 'Answer directly in text; do not use tools.\n%s' "$SRC" > "$PF"
|
|
fi
|
|
run_grok 180 --disable-web-search --max-turns 3
|
|
txt="$(jfield text)"
|
|
if [ -n "$txt" ]; then printf '%s\n' "$txt"; else
|
|
echo "[$SELF] no text (stopReason=$(jfield stopReason)); raw: $OUT" >&2; exit 1; fi
|
|
;;
|
|
image)
|
|
[ -z "${1:-}" ] && { echo "usage: $SELF image \"<prompt>\" [out.png]" >&2; exit 2; }
|
|
out="${2:-grok-image.png}"
|
|
printf 'Use your image_gen tool to generate exactly this image, save it, then stop. Image: %s' "$1" > "$PF"
|
|
run_grok 240 --disable-web-search --max-turns 12
|
|
sid="$(jfield sessionId)"; art="$(find_artifact "$sid" images)"
|
|
if [ -n "$art" ] && [ -f "$art" ]; then cp -f "$art" "$out"
|
|
echo "[$SELF] image OK -> $out (session $sid)"
|
|
else echo "[$SELF] no image artifact (session=$sid, stopReason=$(jfield stopReason))" >&2; exit 1; fi
|
|
;;
|
|
video)
|
|
[ -z "${1:-}" ] || [ -z "${2:-}" ] && { echo "usage: $SELF video \"<prompt>\" <input-image> [out.mp4]" >&2; exit 2; }
|
|
input="$2"; out="${3:-grok-video.mp4}"
|
|
[ -f "$input" ] || { echo "[$SELF] input image not found: $input" >&2; exit 2; }
|
|
cp -f "$input" "$WORK/input.jpg"
|
|
printf 'Use your image_to_video tool to animate input.jpg (in the current directory) into a short clip, save it, then stop. Motion: %s' "$1" > "$PF"
|
|
run_grok 360 --disable-web-search --max-turns 20
|
|
sid="$(jfield sessionId)"; art="$(find_artifact "$sid" videos)"
|
|
if [ -n "$art" ] && [ -f "$art" ]; then cp -f "$art" "$out"
|
|
echo "[$SELF] video OK -> $out (session $sid)"
|
|
else echo "[$SELF] no video artifact (session=$sid, stopReason=$(jfield stopReason))" >&2; exit 1; fi
|
|
;;
|
|
xsearch)
|
|
[ -z "${1:-}" ] && { echo "usage: $SELF xsearch \"<query>\"" >&2; exit 2; }
|
|
printf 'Use your web_search and X/Twitter search tools to answer this, cite sources, then stop: %s' "$1" > "$PF"
|
|
run_grok 150 --max-turns 6
|
|
txt="$(jfield text)"
|
|
if [ -n "$txt" ]; then printf '%s\n' "$txt"; else
|
|
echo "[$SELF] no result (stopReason=$(jfield stopReason))" >&2; exit 1; fi
|
|
;;
|
|
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, and concrete improvements. Be specific.}"
|
|
# Grok reads the file itself (no embedding). Resolve to an absolute path (as given, or
|
|
# repo-relative), then hand grok the native-Windows ABSOLUTE path so read_file works
|
|
# regardless of cwd, and tolerates absolute paths and spaces.
|
|
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
|
|
tgt_win="$(winpath "$resolved")"
|
|
RUN_CWD="$REPO_ROOT"
|
|
printf 'Use your read_file tool to read the file at this absolute path, then do the task and stop. You may also read closely-related files it references if that helps. Do not modify anything.\nPath: %s\n\nTask: %s' "$tgt_win" "$instr" > "$PF"
|
|
run_grok 240 --max-turns 12
|
|
txt="$(jfield text)"
|
|
if [ -z "$txt" ]; then
|
|
# grok-reads-files came back empty (possible read_file regression) -> retry
|
|
# ONCE with the file contents embedded inline, if small enough to inline.
|
|
sz="$(bytes_of_files "$resolved")"
|
|
if [ "$sz" -le "$EMBED_FALLBACK_MAX_BYTES" ]; then
|
|
echo "[$SELF] empty result; retrying with file embedded inline (${sz}B)" >&2
|
|
{ printf 'Review the following file. Answer in text only; do not use tools. Do not modify anything.\nPath: %s\n\nTask: %s\n\n=== BEGIN FILE ===\n' "$resolved" "$instr"; cat "$resolved"; printf '\n=== END FILE ===\n'; } > "$PF"
|
|
txt="$(embed_fallback_run)"
|
|
else
|
|
echo "[$SELF] embed-fallback skipped: file is ${sz}B (> ${EMBED_FALLBACK_MAX_BYTES}B threshold)" >&2
|
|
fi
|
|
fi
|
|
if [ -n "$txt" ]; then printf '%s\n' "$txt"; else
|
|
echo "[$SELF] no result (session=$(jfield sessionId), stopReason=$(jfield stopReason))" >&2; exit 1; fi
|
|
;;
|
|
review-files)
|
|
# review-files [-i "instructions"] <file> [file ...]
|
|
# Reviews a SET of files together (grok read_file's each). Paths may be absolute or
|
|
# repo-relative; spaces are fine. No code is passed as a shell arg -> no quote hell.
|
|
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; }
|
|
list=""
|
|
resolved_files=() # POSIX paths, kept for the embed fallback (sizing + cat)
|
|
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
|
|
resolved_files+=("$r")
|
|
list+="- $(winpath "$r")
|
|
"
|
|
done
|
|
RUN_CWD="$REPO_ROOT"
|
|
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_grok 300 --max-turns 24
|
|
txt="$(jfield text)"
|
|
if [ -z "$txt" ]; then
|
|
# read_file path empty -> retry ONCE with all file contents embedded inline,
|
|
# if the combined size is under the inline threshold.
|
|
sz="$(bytes_of_files "${resolved_files[@]}")"
|
|
if [ "$sz" -le "$EMBED_FALLBACK_MAX_BYTES" ]; then
|
|
echo "[$SELF] empty result; retrying with ${#resolved_files[@]} file(s) embedded inline (${sz}B)" >&2
|
|
{
|
|
printf 'Review the following files together as a unit. Answer in text only; do not use tools. Do not modify anything.\n\nTask: %s\n' "$instr"
|
|
for r in "${resolved_files[@]}"; do
|
|
printf '\n=== BEGIN FILE: %s ===\n' "$r"; cat "$r"; printf '\n=== END FILE: %s ===\n' "$r"
|
|
done
|
|
} > "$PF"
|
|
txt="$(embed_fallback_run)"
|
|
else
|
|
echo "[$SELF] embed-fallback skipped: combined files are ${sz}B (> ${EMBED_FALLBACK_MAX_BYTES}B threshold)" >&2
|
|
fi
|
|
fi
|
|
if [ -n "$txt" ]; then printf '%s\n' "$txt"; else
|
|
echo "[$SELF] no result (session=$(jfield sessionId), stopReason=$(jfield stopReason))" >&2; exit 1; fi
|
|
;;
|
|
review-diff)
|
|
# review-diff [-C <repo-dir>] [-i "instructions"] <gitref> [-- <pathspec...>]
|
|
# Reviews `git diff <gitref>` from <repo-dir> (default repo root; use -C for a submodule,
|
|
# e.g. -C projects/msp-tools/guru-rmm). The diff is written to the prompt file (not a shell
|
|
# arg) -> no quote hell; grok can read_file changed files for full context (cwd=repo-dir).
|
|
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; }
|
|
RUN_CWD="$gdir" # changed-file paths in the diff are relative to this repo root
|
|
{ printf 'Review the following unified git diff. %s\nYou may use read_file on any changed file (paths in the diff are relative to your current directory; strip the a/ b/ prefixes) for full context. Do not modify anything.\n\n=== BEGIN DIFF ===\n' "$instr"; cat "$TMP/diff.txt"; printf '\n=== END DIFF ===\n'; } > "$PF"
|
|
run_grok 300 --max-turns 20
|
|
txt="$(jfield text)"
|
|
if [ -z "$txt" ]; then
|
|
# If even the diff review (which already embeds the diff but invites read_file
|
|
# for context) came back empty, retry ONCE in the strict no-tools text path
|
|
# with just the diff inline, provided the diff is under the inline threshold.
|
|
sz="$(bytes_of_files "$TMP/diff.txt")"
|
|
if [ "$sz" -le "$EMBED_FALLBACK_MAX_BYTES" ]; then
|
|
echo "[$SELF] empty result; retrying with diff embedded inline, no tools (${sz}B)" >&2
|
|
{ printf 'Review the following unified git diff. %s\nAnswer in text only; do not use tools. Do not modify anything.\n\n=== BEGIN DIFF ===\n' "$instr"; cat "$TMP/diff.txt"; printf '\n=== END DIFF ===\n'; } > "$PF"
|
|
txt="$(embed_fallback_run)"
|
|
else
|
|
echo "[$SELF] embed-fallback skipped: diff is ${sz}B (> ${EMBED_FALLBACK_MAX_BYTES}B threshold)" >&2
|
|
fi
|
|
fi
|
|
if [ -n "$txt" ]; then printf '%s\n' "$txt"; else
|
|
echo "[$SELF] no result (session=$(jfield sessionId), stopReason=$(jfield stopReason))" >&2; exit 1; fi
|
|
;;
|
|
raw)
|
|
"$GROK" "$@"
|
|
;;
|
|
*)
|
|
echo "[$SELF] unknown mode '$MODE' (use text|verify|image|video|xsearch|review|review-files|review-diff|raw)" >&2; exit 2 ;;
|
|
esac
|