# Cross-agent CLI for other agents to use; currently supports claude, codex, cursor agent, copilot, and grok.

> Source: <https://gist.github.com/jamonholmgren/92ae359ec929fcac3e1b7f691c1bf463>
> Published: 2026-07-22 19:43:48+00:00

| #!/usr/bin/env bash | |
| # | |
| # agent_cli — one front door for cross-agent work: read-only review/research | |
| # OR write-capable edit/refactor. | |
| # | |
| # Generalizes agent_review (read-only only) by adding an optional trailing | |
| # `mode` arg. `review` (default) keeps every agent read-only — identical to | |
| # agent_review. `edit` flips each CLI into its write-capable mode so a worker | |
| # can apply targeted refactors/edits. Append `edit` to allow writes. | |
| # | |
| # Session continuity is explicit: the first arg is either `new` (fresh | |
| # session) or a session ID from a previous run (resume with full context). | |
| # There is no implicit "continue last session" — you always say which. | |
| # | |
| # Wraps the five CLI agents (copilot, codex, claude, cursor, grok) behind a single | |
| # signature so one doc describes one wrapper instead of five. This script owns the | |
| # per-CLI transport quirks (read-only vs write mode, stdin redirect, effort | |
| # flag mapping, default models). When a CLI's API changes, fix it HERE. | |
| # | |
| # Usage: | |
| # Tools/agent_cli <session ID | new> <agent> <model|default> <effort|default> "<prompt>" [mode] | |
| # | |
| # <session> `new` starts a fresh session; a session ID resumes that session. | |
| # Every run prints `session: <id>` on stderr for later resumption. | |
| # IDs are per-agent (claude/copilot/grok UUIDs, codex thread ids, | |
| # cursor chat ids) — resume with the same agent that minted the id. | |
| # <agent> copilot | codex | claude | cursor | grok | |
| # <model> a model id for that agent, or `default` (see DEFAULT_MODEL_* below) | |
| # <effort> low | medium | high | none | default (mapped per agent) | |
| # <prompt> the prompt; pass `-` to read it from stdin | |
| # [mode] review (default, read-only) | edit (write-capable) | |
| # | |
| # Examples: | |
| # Tools/agent_cli new copilot default high "Review the uncommitted diff." | |
| # Tools/agent_cli new codex gpt-5.6-sol high "Review Scripts/TreeRegion.gd for perf." | |
| # Tools/agent_cli new claude default default "Refactor Scripts/Foo.gd: extract helper." edit | |
| # Tools/agent_cli 0198c2… claude default default "Now run the tests you suggested." edit | |
| # git diff main...HEAD | Tools/agent_cli new claude default high - | |
| # | |
| # review mode runs READ-ONLY (no edits); edit mode allows writes within the | |
| # repo. The repo is the current git toplevel (override with REPO=/path). | |
| # List models with: Tools/agent_cli <agent> --list-models | |
| set -euo pipefail | |
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| # shellcheck source=/dev/null | |
| source "$SCRIPT_DIR/colors.sh" | |
| REPO="${REPO:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" | |
| # Per-agent defaults. Selection rationale lives in AI_MODEL_COMPARISON.md. | |
| DEFAULT_MODEL_copilot="auto" # Auto grants more token usage than pinning a model. Free tokens. | |
| DEFAULT_MODEL_codex="gpt-5.6-terra" | |
| DEFAULT_MODEL_claude="claude-opus-4-8" | |
| DEFAULT_MODEL_cursor="composer-2.5" | |
| DEFAULT_MODEL_grok="grok-4.5" | |
| DEFAULT_EFFORT_copilot="low" # Informational for auto, which rejects explicit effort. | |
| DEFAULT_EFFORT_codex="high" # Benchmark-backed routine review/final-edit setting. | |
| DEFAULT_EFFORT_claude="medium" # Normal work; use claude-fable-5 low for very hard tasks. | |
| DEFAULT_EFFORT_cursor="low" # Informational; Cursor effort is encoded in model ids. | |
| DEFAULT_EFFORT_grok="high" # Unbenchmarked secondary perspective; use high effort. | |
| usage() { | |
| cat <<'EOF' | |
| Usage: Tools/agent_cli <session ID | new> <agent> <model|default> <effort|default> "<prompt>" [mode] | |
| session `new` for a fresh session, or a session ID to resume (printed as | |
| `session: <id>` on stderr by every run; IDs are per-agent) | |
| agent copilot | codex | claude | cursor | grok | |
| model a model id, or `default` | |
| effort low | medium | high | none | default | |
| prompt the prompt; `-` reads from stdin | |
| mode review (default, read-only) | edit (write-capable) | |
| review mode runs read-only; edit mode allows writes within the repo. | |
| Auth/health checks: | |
| Tools/agent_cli <agent> --check-auth # Test auth for one agent | |
| Tools/agent_cli --doctor # Test all five agents | |
| List models: Tools/agent_cli <agent> --list-models | |
| EOF | |
| } | |
| die() { echo -e "${RED}agent_cli: $*${NC}" >&2; exit 1; } | |
| note() { echo -e "${DKGRAY}» $*${NC}" >&2; } | |
| REVIEW_COMPLETION_CONTRACT='Review completion contract: | |
| - After all tool use, always return a final answer on stdout. Do not stop after a preamble, progress update, or tool result. | |
| - State the findings with severity and file/line references. If there are no findings, state PASS explicitly. | |
| - Do not use /tmp or another file as the only report channel; the caller only receives your final stdout. | |
| - End with exactly one of these lines: | |
| REVIEW_COMPLETE: PASS | |
| REVIEW_COMPLETE: FINDINGS' | |
| finish_result() { | |
| local result="$1" status="$2" | |
| printf '%s\n' "$result" | |
| [ "$status" -eq 0 ] || exit "$status" | |
| if [ "$MODE" = "review" ] && [ "${AGENT_CLI_SKIP_REVIEW_COMPLETION:-0}" != "1" ]; then | |
| local last_line | |
| last_line="$(awk 'NF { line = $0 } END { print line }' <<<"$result")" | |
| case "$last_line" in | |
| "REVIEW_COMPLETE: PASS"|"REVIEW_COMPLETE: FINDINGS") ;; | |
| *) | |
| echo "agent_cli: reviewer exited 0 without the required final verdict; review is incomplete, not PASS" >&2 | |
| exit 2 | |
| ;; | |
| esac | |
| fi | |
| } | |
| [ $# -ge 1 ] || { usage >&2; exit 1; } | |
| # Flag forms keep their original shapes (<agent> --list-models, | |
| # <agent> --check-auth, --doctor); the run form is session-first. | |
| if [ "${2:-}" = "--list-models" ] || [ "${2:-}" = "models" ] || [ "${2:-}" = "--check-auth" ] \ | |
| || [ "$1" = "--doctor" ] || [ "$1" = "doctor" ]; then | |
| AGENT="$1"; shift | |
| else | |
| SESSION="$1"; shift | |
| case "$SESSION" in | |
| copilot|codex|claude|cursor|grok) | |
| die "first arg is now <session ID | new> (got agent '$SESSION'); use \`new\` for a fresh session" ;; | |
| "") die "empty session arg (expected a session ID or \`new\`)" ;; | |
| esac | |
| [ $# -ge 1 ] || { usage >&2; die "need <agent>"; } | |
| AGENT="$1"; shift | |
| fi | |
| # --list-models shortcut | |
| if [ "${1:-}" = "--list-models" ] || [ "${1:-}" = "models" ]; then | |
| case "$AGENT" in | |
| cursor) exec cursor-agent --list-models ;; | |
| codex) exec codex --help ;; # codex enumerates via -m; no list subcommand | |
| copilot) die "copilot has no headless model list; run \`copilot\` then /model. Known good: gpt-5.4, claude-sonnet-4-6, auto" ;; | |
| claude) die "claude model ids: claude-fable-5, claude-opus-4-8, claude-sonnet-5, claude-haiku-4-5-20251001" ;; | |
| grok) exec grok models ;; | |
| *) die "unknown agent '$AGENT'" ;; | |
| esac | |
| fi | |
| # Auth check functions | |
| check_agent_auth() { | |
| local agent="$1" | |
| local model; v="DEFAULT_MODEL_$agent"; model="${!v:-}" | |
| [ -z "$model" ] && die "unknown agent '$agent'" | |
| note "checking $agent auth (model: $model)..." | |
| local out exit_code=0 | |
| out="$(AGENT_CLI_SKIP_REVIEW_COMPLETION=1 "$0" new "$agent" "$model" none "Reply with exactly: OK" review 2>&1)" || exit_code=$? | |
| if grep -qi "OK" <<<"$out"; then | |
| echo "${GREEN}✓${NC} $agent: authenticated" >&2 | |
| return 0 | |
| elif grep -qi "exceeded your monthly quota\|quota exceeded\|rate limit" <<<"$out"; then | |
| echo "${RED}✗${NC} $agent: authenticated but out of quota / rate limited" >&2 | |
| echo " Wait for the quota window to reset or use another agent" >&2 | |
| return 1 | |
| else | |
| echo "${RED}✗${NC} $agent: authentication failed or no response" >&2 | |
| echo " Run the $agent CLI interactively to re-authenticate" >&2 | |
| [ "$exit_code" -ne 0 ] && echo " Exit code: $exit_code" >&2 | |
| return 1 | |
| fi | |
| } | |
| # --check-auth shortcut | |
| if [ "${1:-}" = "--check-auth" ]; then | |
| check_agent_auth "$AGENT" || exit 1 | |
| exit 0 | |
| fi | |
| # --doctor checks all agents | |
| if [ "$AGENT" = "--doctor" ] || [ "$AGENT" = "doctor" ]; then | |
| failures=0 | |
| for ag in copilot codex claude cursor grok; do | |
| check_agent_auth "$ag" || failures=$((failures + 1)) | |
| done | |
| [ $failures -eq 0 ] && { echo "${GREEN}All agents authenticated${NC}" >&2; exit 0; } | |
| echo "${RED}$failures agent(s) failed authentication${NC}" >&2 | |
| exit 1 | |
| fi | |
| [ $# -ge 3 ] || { usage >&2; die "need <model> <effort> <prompt>"; } | |
| MODEL="$1"; EFFORT="$2"; PROMPT="$3"; MODE="${4:-review}" | |
| [ "$MODEL" = "default" ] && { v="DEFAULT_MODEL_$AGENT"; MODEL="${!v:-}"; [ -n "$MODEL" ] || die "unknown agent '$AGENT'"; } | |
| [ "$EFFORT" = "default" ] && { v="DEFAULT_EFFORT_$AGENT"; EFFORT="${!v:-}"; [ -n "$EFFORT" ] || die "no default effort for '$AGENT'"; } | |
| [ "$PROMPT" = "-" ] && PROMPT="$(cat)" | |
| [ -n "$PROMPT" ] || die "empty prompt" | |
| case "$MODE" in | |
| review|edit) ;; | |
| *) die "unknown mode '$MODE' (expected review | edit)" ;; | |
| esac | |
| if [ "$MODE" = "review" ] && [ "${AGENT_CLI_SKIP_REVIEW_COMPLETION:-0}" != "1" ]; then | |
| PROMPT="$(printf '%s\n\n%s' "$PROMPT" "$REVIEW_COMPLETION_CONTRACT")" | |
| fi | |
| case "$AGENT" in | |
| copilot) | |
| command -v copilot >/dev/null 2>&1 || die "copilot CLI not found" | |
| # review: --plan = read-only/planning (no edits). edit: drop --plan so | |
| # the agent can write. --allow-all-tools suppresses permission prompts on | |
| # tools (required non-interactive). Some models (auto, *-4.5) reject | |
| # --effort, so retry without it on error. | |
| eff=() | |
| [ "$EFFORT" != "none" ] && [ "$MODEL" != "auto" ] && eff=(--effort "$EFFORT") | |
| plan=(--plan) | |
| [ "$MODE" = "edit" ] && plan=() | |
| # --session-id both names a new session and resumes an existing one. | |
| sess_id="$SESSION" | |
| [ "$sess_id" = "new" ] && sess_id="$(uuidgen | tr '[:upper:]' '[:lower:]')" | |
| note "copilot --model $MODEL ${eff[*]:-} ${plan[*]:-} ($MODE)" | |
| note "session: $sess_id" | |
| status=0 | |
| out="$(copilot -p "$PROMPT" --model "$MODEL" --session-id "$sess_id" ${eff[@]+"${eff[@]}"} ${plan[@]+"${plan[@]}"} --allow-all-tools --no-color -C "$REPO" </dev/null 2>&1)" || status=$? | |
| if grep -q "does not support reasoning effort" <<<"$out"; then | |
| note "model rejects --effort; retrying without it" | |
| status=0 | |
| out="$(copilot -p "$PROMPT" --model "$MODEL" --session-id "$sess_id" ${plan[@]+"${plan[@]}"} --allow-all-tools --no-color -C "$REPO" </dev/null 2>&1)" || status=$? | |
| fi | |
| finish_result "$out" "$status" | |
| ;; | |
| codex) | |
| command -v codex >/dev/null 2>&1 || die "codex CLI not found" | |
| sandbox="read-only" | |
| [ "$MODE" = "edit" ] && sandbox="workspace-write" | |
| status=0 | |
| if [ "$SESSION" = "new" ]; then | |
| note "codex exec -m $MODEL model_reasoning_effort=$EFFORT -s $sandbox ($MODE)" | |
| raw="$(codex exec --strict-config --json -m "$MODEL" -c model_reasoning_effort="$EFFORT" \ | |
| -s "$sandbox" -C "$REPO" "$PROMPT" </dev/null)" || status=$? | |
| else | |
| # `codex exec resume` has no -s/-C flags; sandbox goes through config | |
| # and the working directory through a subshell cd. | |
| note "codex exec resume $SESSION -m $MODEL model_reasoning_effort=$EFFORT sandbox_mode=$sandbox ($MODE)" | |
| raw="$( (cd "$REPO" && codex exec resume --strict-config --json -m "$MODEL" \ | |
| -c model_reasoning_effort="$EFFORT" -c sandbox_mode="$sandbox" \ | |
| "$SESSION" "$PROMPT") </dev/null)" || status=$? | |
| fi | |
| result="$(printf '%s\n' "$raw" | python3 -c ' | |
| import sys, json | |
| message = "" | |
| thread_id = "" | |
| for line in sys.stdin: | |
| line = line.strip() | |
| if not line: continue | |
| try: obj = json.loads(line) | |
| except Exception: continue | |
| if obj.get("type") == "thread.started" and obj.get("thread_id"): | |
| thread_id = obj["thread_id"] | |
| item = obj.get("item", {}) | |
| if obj.get("type") == "item.completed" and item.get("type") == "agent_message" and item.get("text"): | |
| message = item["text"] | |
| if thread_id: | |
| print(f"session: {thread_id}", file=sys.stderr) | |
| print(message if message else "(codex returned no result text)")')" | |
| finish_result "$result" "$status" | |
| ;; | |
| claude) | |
| command -v claude >/dev/null 2>&1 || die "claude CLI not found" | |
| # Headless Claude can silently stall when permission prompts cannot be | |
| # displayed. Use full-access mode in this trusted repo; review-only behavior | |
| # is enforced by the prompt contract, not by Claude's plan-mode permissions. | |
| # Sessions persist so they can be resumed by ID; the explicit --session-id | |
| # still prevents the desktop-managed binary from silently resuming an | |
| # unrelated prior task. | |
| if [ "$SESSION" = "new" ]; then | |
| claude_session_id="$(uuidgen | tr '[:upper:]' '[:lower:]')" | |
| sess=(--session-id "$claude_session_id") | |
| else | |
| claude_session_id="$SESSION" | |
| sess=(--resume "$SESSION") | |
| fi | |
| cprompt="$PROMPT" | |
| [ "$MODE" = "review" ] && cprompt="$(printf 'Review-only: do not edit files.\n\n%s' "$PROMPT")" | |
| note "claude -p --model $MODEL --effort $EFFORT --permission-mode bypassPermissions ($MODE)" | |
| note "session: $claude_session_id" | |
| status=0 | |
| raw="$(cd "$REPO" && printf '%s' "$cprompt" | claude -p \ | |
| --model "$MODEL" --effort "$EFFORT" \ | |
| "${sess[@]}" \ | |
| --permission-mode bypassPermissions --output-format json)" || status=$? | |
| result="$(printf '%s\n' "$raw" | python3 -c ' | |
| import json, sys | |
| raw = sys.stdin.read() | |
| try: | |
| obj = json.loads(raw) | |
| print(obj.get("result") or "(claude returned no result text)") | |
| except Exception: | |
| print(raw.strip() or "(claude returned no result text)")')" | |
| finish_result "$result" "$status" | |
| ;; | |
| cursor) | |
| command -v cursor-agent >/dev/null 2>&1 || die "cursor-agent CLI not found" | |
| # Cursor bakes effort into model ids (e.g. gpt-5.5-high); EFFORT is | |
| # informational only. review: --mode plan blocks edits. edit: cursor's | |
| # write/agent mode is the default, so we OMIT --mode (its only --mode | |
| # choices are plan|ask; there is no `agent` value). Headless -p cannot | |
| # show tool-permission prompts, so --force --trust are required or it | |
| # hangs and exits empty. stream-json reliably emits the final result text | |
| # (text format drops it for agentic runs), so we stream the final result. | |
| cursor_review_mode="${AGENT_CLI_CURSOR_REVIEW_MODE:-plan}" | |
| case "$cursor_review_mode" in plan|ask) ;; *) die "AGENT_CLI_CURSOR_REVIEW_MODE must be plan or ask" ;; esac | |
| cmode=(--mode "$cursor_review_mode"); cprompt="Review-only: do not edit any files. $PROMPT" | |
| [ "$MODE" = "edit" ] && { cmode=(); cprompt="$PROMPT"; } | |
| # A new run pre-mints a chat id via create-chat so the id is known before | |
| # dispatch; both branches then attach with --resume. Re-prefixing | |
| # `Review-only:` on a resumed session makes Composer flake (empty result | |
| # or degenerate repetition), so the prefix is first-message-only; --mode | |
| # plan remains the hard read-only gate on every review call. | |
| cursor_chat_id="$SESSION" | |
| if [ "$cursor_chat_id" = "new" ]; then | |
| cursor_chat_id="$(cursor-agent create-chat)" || die "cursor-agent create-chat failed" | |
| [ -n "$cursor_chat_id" ] || die "cursor-agent create-chat returned no chat id" | |
| else | |
| cprompt="$PROMPT" | |
| fi | |
| note "cursor-agent -p --output-format stream-json ${cmode[*]:-} --force --trust --model $MODEL ($MODE; effort '$EFFORT' n/a)" | |
| note "session: $cursor_chat_id" | |
| status=0 | |
| raw="$(cursor-agent -p --output-format stream-json ${cmode[@]+"${cmode[@]}"} --force --trust \ | |
| --workspace "$REPO" --model "$MODEL" --resume "$cursor_chat_id" \ | |
| "$cprompt")" || status=$? | |
| result="$(printf '%s\n' "$raw" | python3 -c ' | |
| import sys, json | |
| assistant = "" | |
| result = "" | |
| for line in sys.stdin: | |
| line = line.strip() | |
| if not line: continue | |
| try: obj = json.loads(line) | |
| except Exception: continue | |
| if obj.get("type") == "assistant": | |
| content = obj.get("message", {}).get("content", []) | |
| text = "".join(item.get("text", "") for item in content if item.get("type") == "text") | |
| if text.strip(): assistant = text | |
| elif obj.get("type") == "result" and obj.get("result"): | |
| result = obj["result"] | |
| print(assistant if assistant else result if result else "(cursor returned no result text)")')" | |
| finish_result "$result" "$status" | |
| ;; | |
| grok) | |
| command -v grok >/dev/null 2>&1 || die "grok CLI not found" | |
| # Both modes run --permission-mode bypassPermissions: headless grok | |
| # cancels the session (stopReason "Cancelled", answer lost) when plan or | |
| # dontAsk mode hits a tool approval, and --sandbox read-only does not | |
| # actually block writes on this host. Review read-only is therefore | |
| # prompt-level (Review-only prefix) like claude, plus --disallowed-tools | |
| # to strip the direct edit tools; Bash remains available for searching. | |
| # Effort maps to --reasoning-effort (high|medium|low); omit for `none`. | |
| eff=() | |
| [ "$EFFORT" != "none" ] && eff=(--reasoning-effort "$EFFORT") | |
| gate=(); gprompt="$PROMPT" | |
| if [ "$MODE" = "review" ]; then | |
| gate=(--disallowed-tools "Write,Edit,MultiEdit,NotebookEdit") | |
| gprompt="Review-only: do not edit any files. $PROMPT" | |
| fi | |
| if [ "$SESSION" = "new" ]; then | |
| grok_session_id="$(uuidgen | tr '[:upper:]' '[:lower:]')" | |
| sess=(--session-id "$grok_session_id") | |
| else | |
| grok_session_id="$SESSION" | |
| sess=(--resume "$SESSION") | |
| fi | |
| note "grok -p -m $MODEL ${eff[*]:-} --permission-mode bypassPermissions ${gate[*]:-} ($MODE)" | |
| note "session: $grok_session_id" | |
| status=0 | |
| raw="$(grok -p "$gprompt" -m "$MODEL" ${eff[@]+"${eff[@]}"} \ | |
| --permission-mode bypassPermissions ${gate[@]+"${gate[@]}"} \ | |
| "${sess[@]}" --cwd "$REPO" --output-format json </dev/null)" || status=$? | |
| result="$(printf '%s\n' "$raw" | python3 -c ' | |
| import json, sys | |
| raw = sys.stdin.read() | |
| try: | |
| obj = json.loads(raw) | |
| print(obj.get("text") or "(grok returned no result text)") | |
| except Exception: | |
| print(raw.strip() or "(grok returned no result text)")')" | |
| finish_result "$result" "$status" | |
| ;; | |
| *) | |
| die "unknown agent '$AGENT' (expected copilot | codex | claude | cursor | grok)" | |
| ;; | |
| esac |
