#!/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 "" # text / reasoning / second opinion # ask-grok.sh verify "" # adversarial check (text + --check) # ask-grok.sh image "" [out.png] # image_gen -> copy artifact to out # ask-grok.sh video "" [out.mp4] # image_to_video on input image # ask-grok.sh xsearch "" # live X/Twitter + web search # ask-grok.sh review [instructions] # grok read_file's + reviews one file # ask-grok.sh review-files [-i "instr"] [f2 ...] # review a SET of files together # ask-grok.sh review-diff [-C ] [-i "instr"] [-- ] # review a git diff # ask-grok.sh raw # 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)}" # Functional-error logger (skill name "grok"); soft-fails, never breaks the caller. _logerr() { bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "grok" "$@" >/dev/null 2>&1 || true; } # 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 # Policy flags, overridable per mode. Defaults suit the single-context subordinate # calls (text/verify/review): a fixed permission posture and NO subagent fan-out -- # that keeps a one-shot second-opinion call cheap, bounded, and timeout-predictable. # xsearch OVERRIDES both: web_search runs the grok-4.20-multi-agent model, so # --no-subagents made it hang with zero output (root cause, 2026-06-16). See xsearch. GROK_PERM_FLAGS=(--permission-mode dontAsk) GROK_SUBAGENT_FLAGS=(--no-subagents) # Pin xAI's flagship grok-build for the reasoning modes (text/verify/review*) so the # second-opinion quality is deterministic and not at the mercy of the runtime default # (which drifts -- this machine's `grok models` reports grok-composer-2.5-fast, a fast # Cursor coding model). xsearch + image/video clear this to "" to use the runtime # default (xsearch's searcher is a separate model; media gen was verified on default). GROK_MODEL="${GROK_MODEL:-grok-build}" run_grok() { local to="$1"; shift local mflag=(); [ -n "$GROK_MODEL" ] && mflag=(-m "$GROK_MODEL") # 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 \ "${mflag[@]}" "${GROK_PERM_FLAGS[@]}" "${GROK_SUBAGENT_FLAGS[@]}" --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 (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 \"\" | $SELF $MODE --prompt-file " >&2; exit 2; } # Prompt steering + --disable-web-search (below) keeps these text-only; no tools # are needed. NOTE for future hardening: --rules and --disallowed-tools ARE valid # headless flags (per docs/user-guide/14-headless-mode.md) and could hard-restrict # tools if prompt steering ever proves insufficient. --check/--self-verify also # exists, but it makes grok verify its OWN output -- not adversarially critique an # EXTERNAL claim -- so prompt steering is the correct semantic for verify mode. 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; _logerr "grok returned no text" --context "mode=$MODE stopReason=$(jfield stopReason)"; exit 1; fi ;; image) [ -z "${1:-}" ] && { echo "usage: $SELF image \"\" [out.png]" >&2; exit 2; } out="${2:-grok-image.png}" GROK_MODEL="" # media gen verified on the runtime default; don't force grok-build here 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; _logerr "grok produced no image artifact" --context "session=$sid stopReason=$(jfield stopReason)"; exit 1; fi ;; video) [ -z "${1:-}" ] || [ -z "${2:-}" ] && { echo "usage: $SELF video \"\" [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" GROK_MODEL="" # media gen verified on the runtime default; don't force grok-build here 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; _logerr "grok produced no video artifact" --context "session=$sid stopReason=$(jfield stopReason)"; exit 1; fi ;; xsearch) [ -z "${1:-}" ] && { echo "usage: $SELF xsearch \"\"" >&2; exit 2; } # web_search uses the grok-4.20-multi-agent model (subagents ON, --yolo). It frequently TIMES # OUT on multi-part research queries (verified 2026-06-17: 280-286s, no answer, still searching), # and buffered json => total loss. So: (1) streaming-json to salvage any partial that streamed; # (2) a moderate budget; (3) AUTO-FALLBACK to gemini search (the reliable engine, ~120s) when grok # doesn't finish. Net: xsearch returns an answer even though grok alone is flaky here. Q="$1" printf 'Use your web_search tool: run a few TARGETED searches and give a CONCISE answer with source URLs, then stop.\n\nQuestion: %s' "$Q" > "$PF" "$TIMEOUT_CMD" 240 "$GROK" --prompt-file "$(winpath "$PF")" --output-format streaming-json \ --yolo --no-plan --cwd "$(winpath "$RUN_CWD")" --max-turns 14 >"$OUT" 2>"$TMP/err.txt"; GRC=$? ans="$("$PY" -c ' import json,sys t=[] for ln in sys.stdin: ln=ln.strip() if not ln: continue try: e=json.loads(ln) except: continue if e.get("type")=="text": t.append(e.get("data","")) print("".join(t).strip())' < "$OUT")" if [ "$GRC" -eq 0 ] && [ -n "$ans" ]; then printf '%s\n' "$ans"; exit 0; fi echo "[$SELF] grok xsearch did not finish (rc=$GRC) -> falling back to gemini search" >&2 _logerr "grok xsearch incomplete (rc=$GRC); auto-fell back to gemini" --context "mode=xsearch" GEM="$REPO_ROOT/.claude/skills/agy/scripts/ask-gemini.sh" if [ -f "$GEM" ]; then echo "[grok xsearch timed out -> answered via gemini search]"; exec bash "$GEM" search "$Q"; fi [ -n "$ans" ] && { printf '%s\n' "$ans"; exit 0; } # last resort: whatever partial streamed echo "[$SELF] no result (grok timed out; gemini fallback unavailable)" >&2; exit 1 ;; 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, and concrete improvements. Be specific.}" # Grok reads the file itself (no embedding). Resolve to an absolute path (as given, or # relative to $REPO_ROOT), then hand grok the native-Windows ABSOLUTE path so read_file # works regardless of cwd, and tolerates absolute paths and spaces. # 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 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 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; _logerr "grok review returned no result" --context "mode=$MODE session=$(jfield sessionId) stopReason=$(jfield stopReason)"; exit 1; fi ;; review-files) # review-files [-i "instructions"] [file ...] # Reviews a SET of files together (grok read_file's each). Paths may be absolute, # CWD-relative, or relative to $REPO_ROOT ($CLAUDETOOLS_ROOT); spaces are fine. # GOTCHA: a relative path is NOT resolved against 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. No code 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 ...]" >&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; _logerr "grok review returned no result" --context "mode=$MODE session=$(jfield sessionId) stopReason=$(jfield stopReason)"; exit 1; fi ;; review-diff) # review-diff [-C ] [-i "instructions"] [-- ] # Reviews `git diff ` from (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 ] [-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; } 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; _logerr "grok review returned no result" --context "mode=$MODE session=$(jfield sessionId) stopReason=$(jfield stopReason)"; 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