Skip to content

Instantly share code, notes, and snippets.

@jamonholmgren
Created July 22, 2026 19:43
Show Gist options
  • Select an option

  • Save jamonholmgren/92ae359ec929fcac3e1b7f691c1bf463 to your computer and use it in GitHub Desktop.

Select an option

Save jamonholmgren/92ae359ec929fcac3e1b7f691c1bf463 to your computer and use it in GitHub Desktop.
Cross-agent CLI for other agents to use; currently supports claude, codex, cursor agent, copilot, and grok.
#!/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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment