Created
July 22, 2026 19:43
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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