advisory 리뷰어 래퍼 만들기 — graceful degradation 패턴
Codex가 없거나 죽어도 메인 파이프라인은 그대로 흐르게 하는 래퍼 스크립트 패턴을 익혀요.
이 챕터를 끝내면 메인 파이프라인 어디에서나 호출해도 안전한 advisory 리뷰 래퍼 한 개를 만들 수 있어요.
왜 래퍼가 필요한가
Codex CLI를 직접 부르면 다음 상황에서 메인 작업이 멈춰요.
- Codex가 설치 안 되어 있는 머신 (CI 러너, 신규 팀원 노트북)
- 인증이 풀려 있는 상태 (
codex login만료) - 호출이 너무 오래 걸려 push 시간을 잡아먹는 경우
- API 한도 초과·일시 장애
이걸 매 호출 위치마다 처리하면 코드가 지저분해져요. 호출자는 항상 exit 0과 구조화된 결과만 받게 한 곳에서 흡수해버려요. 그 한 곳이 래퍼 스크립트예요.
CLI 명령은 두 갈래로 분리
자동화에서 쓸 codex CLI 호출은 사용 패턴에 따라 두 갈래로 갈라요.
codex review --base <ref>— diff 기반 리뷰.--base/--uncommitted/--commit/--title같은 review 전용 플래그를 내장하고, 본인이 알아서 git diff를 읽어요. pre-push hook처럼 "현재 브랜치 vs 베이스" 흐름엔 이쪽이 정합. (사용 전codex review --help로 본인 codex 버전에서 플래그가 그대로인지 한 번 확인해요. 0.125 시점 기준 정식 서브커맨드지만, 마이너 업데이트마다 검증 권장.)codex exec "<prompt>"— 자유 프롬프트, 비대화 실행. diff 컨텍스트가 없는 ad-hoc 호출, 재현 가능한 단발 작업, "특정 파일만 추출해서 리뷰" 같은 수동 컨텍스트 흐름에 써요.
공식 문서가 자동화 안정 명령으로 codex exec를 권장한다고 해서 무조건 한쪽으로 합치면, review가 본인 git context를 이미 잘 이해한다는 장점을 버려야 해요. 한쪽으로 강제 통일이 아니라 유즈케이스로 나눠서 둘 다 쓰는 게 정답이에요.
공통 옵션:
-c key=value— 설정 오버라이드. JSON으로 파싱되면 JSON, 아니면 문자열 그대로-c model_reasoning_effort=high— 한 번 깊게 보게 (minimal | low | medium | high | xhigh)-c approval_policy=never— 비대화 실행에서 승인 프롬프트 끄기--output-last-message <file>— 최종 응답 본문을 파일로 저장 (호출자가 깔끔히 받기 좋음)
최소 래퍼 한 벌
다음 래퍼는 두 호출 모드(--base <ref> 위임 / 자유 prompt)를 흡수하고, 출력은 JSON envelope 기본 + --raw 사람용 두 가지로 갈라요. 둘을 한 포맷으로 합치는 건 가능해요(예: Markdown 본문 첫 줄에 <!-- status: OK --> 같은 머신 헤더). 다만 호출자 입장에서 jq 한 줄 분기가 가장 깔끔하다는 DX 판단으로 envelope을 기본으로 골랐어요. 사람 터미널에서 JSON이 가독성이 떨어지니 --raw 옵션을 둔 거예요.
#!/usr/bin/env bash
# advisory Codex review wrapper
# Usage:
# ./codex-review.sh --base origin/main [--timeout 120] [--raw]
# ./codex-review.sh "<prompt>" [--timeout 120] [--raw]
# ./codex-review.sh -- "-flag-prompt" # use -- to pass a prompt starting with -
#
# Always exits 0 — never blocks the caller.
# Default output: JSON envelope { status, reason, body }
# With --raw: human-readable Markdown for terminal display.
set -u
TIMEOUT=120
BASE_REF=""
PROMPT=""
RAW=0
# JSON string escape: prefer jq, fall back to a small awk encoder so the
# wrapper still emits valid JSON when jq is missing.
json_escape() {
if command -v jq >/dev/null 2>&1; then
jq -Rs .
else
awk 'BEGIN{ORS=""; printf "\""}
{gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); gsub(/\r/,"\\r"); gsub(/\t/,"\\t"); printf "%s\\n",$0}
END{printf "\""}'
fi
}
emit() {
# $1=status $2=reason $3(optional)=body file path
local status="$1" reason="$2" body_file="${3-}"
if [[ $RAW -eq 1 ]]; then
echo "## [$status] $reason"
if [[ -n "$body_file" && -s "$body_file" ]]; then
echo
cat "$body_file"
fi
else
local body='""'
if [[ -n "$body_file" && -s "$body_file" ]]; then
body=$(json_escape < "$body_file")
fi
printf '{"status":"%s","reason":"%s","body":%s}\n' "$status" "$reason" "$body"
fi
}
# Fail-soft argument parsing — if a flag is missing its value, emit ERROR
# instead of letting `set -u` kill the script (would break "always exits 0").
need_value() {
local flag="$1" val="${2-__MISSING__}"
if [[ "$val" == "__MISSING__" ]]; then
emit "ERROR" "missing value for ${flag}"
exit 0
fi
printf '%s' "$val"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--base) BASE_REF=$(need_value --base "${2-__MISSING__}"); shift 2 ;;
--timeout) TIMEOUT=$(need_value --timeout "${2-__MISSING__}"); shift 2 ;;
--raw) RAW=1; shift ;;
--) shift; PROMPT="${1-}"; break ;;
-*) emit "ERROR" "unknown flag: $1"; exit 0 ;;
*) PROMPT="$1"; shift ;;
esac
done
# bash-native timeout (no gtimeout/timeout dependency).
# Returns 124 only if we actually had to KILL — if the child finished during
# the TERM grace period, we keep its real exit code.
run_with_timeout() {
local seconds="$1"; shift
"$@" &
local pid=$!
local waited=0
while kill -0 "$pid" 2>/dev/null; do
if (( waited >= seconds )); then
kill -TERM "$pid" 2>/dev/null
local grace=0
while (( grace < 2 )) && kill -0 "$pid" 2>/dev/null; do
sleep 1
grace=$((grace + 1))
done
if kill -0 "$pid" 2>/dev/null; then
kill -KILL "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
return 124
fi
wait "$pid" 2>/dev/null
return $?
fi
sleep 1
waited=$((waited + 1))
done
wait "$pid" 2>/dev/null
return $?
}
if [[ -z "$BASE_REF" && -z "$PROMPT" ]]; then
emit "ERROR" "neither --base nor prompt provided"
exit 0
fi
if ! command -v codex >/dev/null 2>&1; then
emit "SKIPPED" "codex CLI not installed"
exit 0
fi
# Auth check: `codex login status` exits 0 when logged in. Combined with the
# OPENAI_API_KEY env var fallback (CI), this covers both auth modes without
# parsing human-readable output.
if [[ -z "${OPENAI_API_KEY:-}" ]] && ! codex login status >/dev/null 2>&1; then
emit "SKIPPED" "codex not authenticated (run \`codex login\` or set OPENAI_API_KEY)"
exit 0
fi
OUT_FILE=$(mktemp "${TMPDIR:-/tmp}/codex-review.XXXXXX")
trap 'rm -f "$OUT_FILE"' EXIT
if [[ -n "$BASE_REF" ]]; then
# diff-based: let codex review read git context itself
CODEX_ARGS=(
review
-c model_reasoning_effort=high
-c approval_policy=never
--base "$BASE_REF"
--output-last-message "$OUT_FILE"
)
else
# free-form prompt: use codex exec
CODEX_ARGS=(
exec
-c model_reasoning_effort=high
-c approval_policy=never
--output-last-message "$OUT_FILE"
"$PROMPT"
)
fi
run_with_timeout "$TIMEOUT" codex "${CODEX_ARGS[@]}" >/dev/null 2>&1
EXIT_CODE=$?
case "$EXIT_CODE" in
0) emit "OK" "advisory review" "$OUT_FILE" ;;
124) emit "TIMEOUT" "codex timed out after ${TIMEOUT}s" ;;
*) emit "ERROR" "codex exited with code ${EXIT_CODE}" ;;
esac
exit 0
핵심 설계 결정 5개:
- 두 모드 분리 —
--base면codex review로 위임 (Codex가 git diff를 직접 읽음), 아니면codex exec로 자유 prompt - JSON envelope 기본 — 자동화/슬래시 커맨드 호출자가
jq로 깔끔하게 분기 --rawMarkdown — pre-push hook처럼 사람이 터미널에서 보는 용도- bash-native 타임아웃 —
gtimeout/timeout의존 제거. macOS 기본 환경에서 둘 다 없어 무한 대기하던 dead code를 폴링으로 대체 set -u— 미정의 변수 사고 방지. 단${OPENAI_API_KEY:-}처럼 옵셔널 변수는 명시적 default
처리하는 분기 6가지:
- 인자 누락 →
ERROR - CLI 미설치 →
SKIPPED - 인증 실패 →
SKIPPED - 타임아웃 →
TIMEOUT - 비정상 종료 →
ERROR - 정상 →
OK+ 본문
호출 측 패턴
A. diff 모드 (권장) — codex review에 위임
git context가 있는 흐름이면 base ref 한 개만 넘기고 끝이에요. Codex가 자체적으로 git diff를 읽어요.
# 사람이 보는 hook 출력
bash scripts/codex-review.sh --base origin/main --raw
# 자동화 호출 (JSON envelope)
RESULT=$(bash scripts/codex-review.sh --base origin/main --timeout 120)
echo "$RESULT" | jq -r '.body'
호출자가 prompt에 diff를 끼워넣을 필요가 사라져요. 이게 우리가 review/exec를 분리한 가장 큰 이유예요.
B. 자유 prompt 모드 — 수동 컨텍스트
특정 파일만 골라 리뷰하거나, diff와 무관한 ad-hoc 질문이면 자유 prompt로 부르고 직접 컨텍스트를 짜요.
PROMPT=$(cat <<EOF
Review src/payments/refund.ts for race conditions in the refund flow.
Report each finding as [SEVERITY] file:line — description.
Only report real issues; skip stylistic preferences.
EOF
)
RESULT=$(bash scripts/codex-review.sh "$PROMPT" --timeout 120)
상태 분기
JSON envelope 기본 모드에서 분기는 한 줄이에요.
RESULT=$(bash scripts/codex-review.sh --base origin/main)
STATUS=$(echo "$RESULT" | jq -r '.status')
case "$STATUS" in
OK) echo "advisory 결과 도착"; echo "$RESULT" | jq -r '.body' ;;
SKIPPED) echo "advisory 스킵 — 환경 문제" ;;
TIMEOUT) echo "advisory 시간 초과" ;;
ERROR) echo "advisory 에러 — 다음 단계 진행" ;;
esac
어느 분기든 다음 단계는 그대로 진행돼요. 분기는 사용자에게 표시할 라벨을 다르게 할 때만 쓰는 거예요.
프롬프트 짜는 법 (자유 prompt 모드)
자유 prompt(B 모드) 또는 --base를 못 쓰는 환경(상위 호환성/예외 흐름)에서 직접 diff를 끼워야 할 때만 다음 패턴이 필요해요. 일반 흐름은 A 모드로 충분해요.
BASE_REF="${CODEX_BASE_BRANCH:-origin/main}"
CHANGED=$(git diff --name-only "${BASE_REF}...HEAD" | tr '\n' ' ')
# head -c는 hunk를 라인 도중에 자를 수 있어 diff 포맷이 깨져요.
# 라인 경계를 보존하려고 head -n으로 줄 수 상한을 잡습니다.
# 800줄이면 보통 50~80KB 정도 — 토큰 폭주 없이 큰 컨텍스트 커버.
DIFF_BODY=$(git diff "${BASE_REF}...HEAD" -- '*.ts' '*.tsx' '*.js' '*.py' '*.sql' \
| head -n 800)
PROMPT=$(cat <<EOF
Review the following diff for bugs, security issues, type safety, and pattern violations.
Report each finding as [SEVERITY] file:line — description.
Only report real issues; skip stylistic preferences.
Changed files: ${CHANGED}
Diff:
\`\`\`diff
${DIFF_BODY}
\`\`\`
EOF
)
핵심:
- A 모드가 가능하면 무조건 A —
codex review가 직접 git을 읽는 게 항상 더 정확해요 - B 모드 상한은 라인 단위(
head -n) —head -c로 바이트 자르면 hunk 헤더(@@ ... @@)나+/-라인이 도중에 잘려 모델이 잘못 파싱해요 - 확장자 필터 — 락파일/번들 산출물/
.env.example같은 노이즈 차단 - 출력 형식 강제 —
[SEVERITY] file:line — description로 grep/파싱 가능 - "real issues only" — 명시 안 하면 스타일 노이즈가 압도적
시간 예산
advisory 리뷰는 사용자가 결과를 기다릴 만한 시간 안에 끝나야 해요. 그 시간이 지나면 사용자는 결과를 안 봐요.
- push hook 안에서: 60~90초
- 슬래시 커맨드 끝부분: 90~180초
- 백그라운드 잡: 5분 이내
타임아웃 설정은 위 예산 기준으로 잡아요. 더 긴 분석이 필요하면 advisory가 아니라 별도 잡으로 빼야 해요.
다음 챕터 예고
래퍼 한 개로 한 군데만 호출하는 건 시작일 뿐이에요. 다음 챕터에서 이 래퍼를 여러 페이즈로 구성된 슬래시 커맨드 파이프라인에 advisory 페이즈로 끼워 넣어요.