#!/usr/bin/env bash # headless-coding-agent-ci.sh — run a coding agent non-interactively inside CI with hard caps. # # What it does: invokes `claude -p` in bare mode with a turn cap, a dollar cap, the dontAsk # permission mode plus an explicit allow list, captures the JSON result, extracts the cost and # the result text with Python (no jq needed), and fails the job when the agent exits non-zero # or the estimated cost exceeds COST_CAP_USD. # # Inputs (environment): # TASK_PROMPT the task (default: a read-only summary task) # MAX_TURNS agentic turn cap (default 12) # MAX_BUDGET --max-budget-usd passed to the agent (default 2.00) # COST_CAP_USD fail the job above this total_cost_usd (default 1.50) # ALLOWED_TOOLS permission rules (default: Read plus pytest/git diff/git log) # DRY_RUN=1 replace the agent binary with a local Python stub that prints a JSON result # shaped like the real one, so the wrapper can be exercised without credentials. # Run: bash headless-coding-agent-ci.sh (needs ANTHROPIC_API_KEY unless DRY_RUN=1) # DRY_RUN=1 bash headless-coding-agent-ci.sh # Requires: bash, python3 (3.13 tested); the `claude` CLI for real runs. set -euo pipefail TASK_PROMPT="${TASK_PROMPT:-Summarize what this repository does in five bullet points. Do not edit files.}" MAX_TURNS="${MAX_TURNS:-12}" MAX_BUDGET="${MAX_BUDGET:-2.00}" COST_CAP_USD="${COST_CAP_USD:-1.50}" ALLOWED_TOOLS="${ALLOWED_TOOLS:-Read,Bash(python -m pytest *),Bash(git diff *),Bash(git log *)}" OUT="${OUT:-agent-result.json}" if [[ "${DRY_RUN:-0}" == "1" ]]; then # A stand-in for the agent: prints one JSON object with the fields the wrapper reads. # It is NOT a model call; it exists so the control flow below can be tested offline. AGENT=(python -c 'import json,sys; print(json.dumps({"type":"result","subtype":"success","is_error":False,"num_turns":3,"duration_ms":1200,"total_cost_usd":0.0421,"session_id":"dry-run-session","result":"DRY RUN: the stub printed this instead of a model answer. Prompt length: %d chars" % len(sys.argv[-1])}))') else AGENT=(claude --bare -p --output-format json --permission-mode dontAsk --permission-prompts none --allowedTools "$ALLOWED_TOOLS" --max-turns "$MAX_TURNS" --max-budget-usd "$MAX_BUDGET") fi echo "== running agent (turns<=$MAX_TURNS, budget<=\$$MAX_BUDGET, cap=\$$COST_CAP_USD, dry_run=${DRY_RUN:-0})" set +e "${AGENT[@]}" "$TASK_PROMPT" > "$OUT" STATUS=$? set -e echo "== agent exit status: $STATUS" if [[ $STATUS -ne 0 ]]; then echo "agent failed; raw output follows"; cat "$OUT"; exit $STATUS fi # Extract cost and result without jq; exit 3 when the cost cap is exceeded. python - "$OUT" "$COST_CAP_USD" <<'PY' import json, sys path, cap = sys.argv[1], float(sys.argv[2]) with open(path, encoding="utf-8") as f: data = json.load(f) cost = float(data.get("total_cost_usd") or 0.0) print(f"== session {data.get('session_id')} subtype={data.get('subtype')} turns={data.get('num_turns')} cost=${cost:.4f}") print("== result:") print(data.get("result", "").strip()) if data.get("is_error"): print("== agent reported is_error=true"); sys.exit(2) if cost > cap: print(f"== cost ${cost:.4f} exceeds cap ${cap:.2f}"); sys.exit(3) PY echo "== ok"