#!/usr/bin/env bash # OpusAPI → Codex CLI 一键配置(先清洗再注入) # # curl -fsSL https://gpt.opusapi.xyz/setup-codex.sh | bash -s -- --key 'sk-你的密钥' # bash setup-codex.sh --uninstall # bash setup-codex.sh --check # # 对齐本机 ~/.codex:model_provider=custom + wire_api=responses + auth.json OPENAI_API_KEY { set -euo pipefail DEFAULT_BASE="https://api.opusapi.xyz/v1" CONFIG_DIR="${CODEX_HOME:-$HOME/.codex}" CONFIG_FILE="$CONFIG_DIR/config.toml" AUTH_FILE="$CONFIG_DIR/auth.json" STATE_FILE="$CONFIG_DIR/.opusapi-codex-state.json" MARK_BEGIN="# >>> opusapi-codex >>>" MARK_END="# <<< opusapi-codex <<<" API_KEY="${OPUSAPI_API_KEY:-}" BASE_URL="${OPUSAPI_BASE_URL:-$DEFAULT_BASE}" DO_VERIFY=1 UNINSTALL=0 CHECK=0 if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then C_R=$'\033[31m'; C_G=$'\033[32m'; C_Y=$'\033[33m'; C_B=$'\033[34m'; C_0=$'\033[0m' else C_R=; C_G=; C_Y=; C_B=; C_0=; fi step(){ printf '%s→%s %s\n' "$C_B" "$C_0" "$1"; } ok(){ printf '%s✔%s %s\n' "$C_G" "$C_0" "$1"; } warn(){ printf '%s⚠%s %s\n' "$C_Y" "$C_0" "$1" >&2; } die(){ printf '%s✖%s %s\n' "$C_R" "$C_0" "$1" >&2; exit 1; } while [ "$#" -gt 0 ]; do case "$1" in --key) API_KEY="${2:-}"; shift 2 ;; --base-url) BASE_URL="${2:-}"; shift 2 ;; --no-verify) DO_VERIFY=0; shift ;; --check) CHECK=1; shift ;; --uninstall) UNINSTALL=1; shift ;; -h|--help) echo "用法: setup-codex.sh --key sk-xxx [--base-url URL] [--check] [--uninstall]" exit 0 ;; *) die "未知参数: $1" ;; esac done command -v python3 >/dev/null 2>&1 || die "缺少 python3" command -v curl >/dev/null 2>&1 || die "缺少 curl" BASE_URL="${BASE_URL%/}" case "$BASE_URL" in https://api.opusapi.xyz|https://gpt.opusapi.xyz|https://opusapi.xyz|https://india.opusapi.xyz) BASE_URL="${BASE_URL}/v1" ;; https://api.opusapi.xyz/v1|https://gpt.opusapi.xyz/v1|https://opusapi.xyz/v1|https://india.opusapi.xyz/v1) ;; *) die "不允许的 base-url: $BASE_URL(推理默认 api.opusapi.xyz;站点 gpt.opusapi.xyz)" ;; esac sanitize_key() { printf '%s' "$1" | tr -d '[:space:]' | sed -e 's/^["'\'']//' -e 's/["'\'']$//' } mask_key() { local k="$1" n=${#1} if [ "$n" -le 12 ]; then printf '%s' '***'; else printf '%s…%s' "${k:0:8}" "${k: -6}"; fi } curl_code() { local out="$1" url="$2"; shift 2 local attempt code=000 for attempt in 1 2 3; do code="$(curl -sS -m 25 -o "$out" -w '%{http_code}' "$url" "$@" 2>/dev/null || true)" [ "$code" != "000" ] && [ -n "$code" ] && { printf '%s' "$code"; return 0; } sleep "$attempt" done printf '%s' "${code:-000}" } verify_key() { local key="$1" base="$2" body code step "探测 $base/models …" body="$(mktemp)" code="$(curl_code "$body" "$base/models" -H "Authorization: Bearer $key" -H "User-Agent: OpusAPI-Codex-Setup/1.0")" if [ "$code" != "200" ]; then warn "GET /models → HTTP ${code:-000}"; head -c 200 "$body"; echo rm -f "$body"; return 1 fi ok "models 可达" rm -f "$body" } run_py() { local mode="$1" key="${2:-}" base="${3:-}" OPUS_MODE="$mode" OPUS_KEY="$key" OPUS_BASE="$base" \ OPUS_CONFIG="$CONFIG_FILE" OPUS_AUTH="$AUTH_FILE" OPUS_STATE="$STATE_FILE" OPUS_DIR="$CONFIG_DIR" \ OPUS_MARK_BEGIN="$MARK_BEGIN" OPUS_MARK_END="$MARK_END" \ python3 <<'PY' import hashlib, json, os, pathlib, re, sys, time mode = os.environ["OPUS_MODE"] cfg_path = pathlib.Path(os.environ["OPUS_CONFIG"]) auth_path = pathlib.Path(os.environ["OPUS_AUTH"]) state_path = pathlib.Path(os.environ["OPUS_STATE"]) cfg_dir = pathlib.Path(os.environ["OPUS_DIR"]) key = os.environ.get("OPUS_KEY") or "" base = (os.environ.get("OPUS_BASE") or "").rstrip("/") mark_b = os.environ["OPUS_MARK_BEGIN"] mark_e = os.environ["OPUS_MARK_END"] OWNED_ROOT_KEYS = { "model_provider", "openai_base_url", "model", "review_model", "model_reasoning_effort", "forced_login_method", "cli_auth_credentials_store", } CHATGPT_AUTH_KEYS = { "tokens", "last_refresh", "access_token", "refresh_token", "id_token", } def backup(p: pathlib.Path, tag: str): if not p.exists(): return bak = p.with_name(p.name + f".bak.{tag}.{time.strftime('%Y%m%d%H%M%S')}") bak.write_bytes(p.read_bytes()) print(f"backup {bak}") def esc(s: str) -> str: return s.replace("\\", "\\\\").replace('"', '\\"') def strip_managed(text: str) -> str: return re.sub(re.escape(mark_b) + r".*?" + re.escape(mark_e) + r"\n?", "", text, flags=re.S) def remove_owned_root_keys(text: str): """Remove only root keys owned by this installer; preserve all TOML tables.""" lines = text.splitlines(keepends=True) out = [] removed = [] in_root = True for line in lines: stripped = line.strip() if stripped.startswith("["): in_root = False if in_root and "=" in stripped and not stripped.startswith("#"): name = stripped.split("=", 1)[0].strip() if name in OWNED_ROOT_KEYS: removed.append(line) continue out.append(line) return "".join(out), removed def collapse(text: str) -> str: text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() + ("\n" if text.strip() else "") def split_root_and_tables(text: str): lines = text.splitlines(keepends=True) for index, line in enumerate(lines): if line.lstrip().startswith("["): return "".join(lines[:index]), "".join(lines[index:]) return text, "" def load_state(): if not state_path.exists(): return {} try: value = json.loads(state_path.read_text(encoding="utf-8")) return value if isinstance(value, dict) else {} except Exception: return {} # --check is deliberately before mkdir and backup: diagnostics must be read-only. raw = cfg_path.read_text(encoding="utf-8") if cfg_path.exists() else "" if raw.startswith("\ufeff"): raw = raw.lstrip("\ufeff") if mode == "check": print(f"config: {cfg_path} exists={cfg_path.exists()}") if cfg_path.exists(): print(" has managed:", mark_b in raw) m = re.search(r'(?m)^openai_base_url\s*=\s*"([^"]+)"', raw) print(" base_url:", m.group(1) if m else "?") print(f"auth: {auth_path} exists={auth_path.exists()}") print(f"state: {state_path} exists={state_path.exists()}") sys.exit(0) cfg_dir.mkdir(parents=True, exist_ok=True) try: cfg_dir.chmod(0o700) except Exception: pass for path in (cfg_path, auth_path, state_path): backup(path, "pre-clean") state = load_state() raw_without_managed = strip_managed(raw) if mode == "uninstall": _, current_owned = remove_owned_root_keys(raw_without_managed) previous = state.get("previous_root_lines") if isinstance(state, dict) else None if previous and not current_owned: raw_without_managed = "".join(previous) + raw_without_managed.lstrip() cfg_path.write_text(collapse(raw_without_managed), encoding="utf-8") try: cfg_path.chmod(0o600) except Exception: pass if auth_path.exists(): try: d = json.loads(auth_path.read_text(encoding="utf-8")) installed_hash = state.get("installed_key_sha256") current = d.get("OPENAI_API_KEY") if isinstance(d, dict) else None current_hash = hashlib.sha256(current.encode()).hexdigest() if isinstance(current, str) else "" if installed_hash and current_hash == installed_hash: d.pop("OPENAI_API_KEY", None) auth_path.write_text(json.dumps(d, indent=2) + "\n", encoding="utf-8") print("cleared installed OPENAI_API_KEY from auth.json") else: print("kept OPENAI_API_KEY because it was changed after install") except Exception as e: print(f"warn auth: {e}", file=sys.stderr) try: state_path.unlink() except FileNotFoundError: pass print("uninstall done") sys.exit(0) raw, current_owned = remove_owned_root_keys(raw_without_managed) previous_root_lines = state.get("previous_root_lines") if state else None if not isinstance(previous_root_lines, list): previous_root_lines = current_owned root_text, tables_text = split_root_and_tables(raw) root_text = collapse(root_text) tables_text = collapse(tables_text) if not key: print("ERROR empty key", file=sys.stderr) sys.exit(2) managed = "\n".join([ mark_b, "# managed by OpusAPI setup-codex.sh — safe ownership block", 'model_provider = "opuscodex"', f'openai_base_url = "{esc(base)}"', 'model = "gpt-5.5"', 'review_model = "gpt-5.5"', 'model_reasoning_effort = "xhigh"', 'forced_login_method = "api"', 'cli_auth_credentials_store = "file"', "", "[model_providers.opuscodex]", 'name = "OpusCodex"', f'base_url = "{esc(base)}"', 'wire_api = "responses"', "requires_openai_auth = true", "", mark_e, "", ]) root_prefix = root_text + ("\n" if root_text else "") final = root_prefix + managed + tables_text cfg_path.write_text(final, encoding="utf-8") try: cfg_path.chmod(0o600) except Exception: pass auth = {"OPENAI_API_KEY": key, "auth_mode": "apikey"} if auth_path.exists(): try: old = json.loads(auth_path.read_text(encoding="utf-8")) if isinstance(old, dict): for name, value in old.items(): if name in CHATGPT_AUTH_KEYS or name in {"OPENAI_API_KEY", "auth_mode"}: continue auth[name] = value except Exception: pass auth_path.write_text(json.dumps(auth, indent=2) + "\n", encoding="utf-8") try: auth_path.chmod(0o600) except Exception: pass state_path.write_text( json.dumps( { "schema_version": 1, "installed_key_sha256": hashlib.sha256(key.encode()).hexdigest(), "previous_root_lines": previous_root_lines, }, indent=2, ) + "\n", encoding="utf-8", ) try: state_path.chmod(0o600) except Exception: pass print(f"wrote {cfg_path}") print(f"wrote {auth_path}") print("OK") PY } if [ "$CHECK" -eq 1 ]; then run_py check exit 0 fi if [ "$UNINSTALL" -eq 1 ]; then step "卸载 Codex OpusAPI 注入…" run_py uninstall ok "已清洗" exit 0 fi if [ -z "$API_KEY" ] && [ -r /dev/tty ]; then printf '%s请粘贴 OpusAPI API Key:%s\n' "$C_B" "$C_0" > /dev/tty IFS= read -r API_KEY < /dev/tty || true fi API_KEY="$(sanitize_key "${API_KEY:-}")" [ -n "$API_KEY" ] || die "请提供 --key sk-xxx" echo printf '%sOpusAPI → Codex(先清洗再注入)%s\n' "$C_B" "$C_0" echo " base_url : $BASE_URL" echo " api_key : $(mask_key "$API_KEY")" echo " config : $CONFIG_FILE" echo if [ "$DO_VERIFY" -eq 1 ]; then verify_key "$API_KEY" "$BASE_URL" || die "密钥探测失败" fi DEFAULT_CODEX_HOME="${HOME}/.codex" codex_app_running(){ pgrep -x ChatGPT >/dev/null 2>&1 || pgrep -x Codex >/dev/null 2>&1 } wait_codex_apps_gone(){ local n=0 while [ "$n" -lt 20 ]; do codex_app_running || return 0 sleep 0.25 n=$((n+1)) done pkill -9 -x ChatGPT >/dev/null 2>&1 || true pkill -9 -x Codex >/dev/null 2>&1 || true } quit_codex_apps(){ case "${CONFIG_DIR}" in "$DEFAULT_CODEX_HOME") ;; *) return 0 ;; esac if ! codex_app_running; then return 0 fi step "退出正在运行的 Codex / ChatGPT 桌面端,避免它把 ChatGPT 登录态写回" pkill -x ChatGPT >/dev/null 2>&1 || true pkill -x Codex >/dev/null 2>&1 || true wait_codex_apps_gone } codex_has_api_login(){ local help [ -x "$1" ] || return 1 help="$("$1" login --help 2>&1 || true)" case "$help" in *--with-api-key*) return 0 ;; esac return 1 } find_codex_bin(){ local p seen="" set -- \ ${OPUSCODEX_CODEX_BIN:+"$OPUSCODEX_CODEX_BIN"} \ "/Applications/ChatGPT.app/Contents/Resources/codex" \ "/Applications/Codex.app/Contents/Resources/codex" \ "$HOME/.local/bin/codex" \ /usr/local/bin/codex \ /opt/homebrew/bin/codex if command -v codex >/dev/null 2>&1; then set -- "$@" "$(command -v codex)" fi for p in "$@"; do [ -n "$p" ] || continue case " $seen " in *" $p "*) continue ;; esac seen="$seen $p" if codex_has_api_login "$p"; then printf '%s' "$p" return 0 fi done return 1 } register_api_login(){ local key="$1" bin bin="$(find_codex_bin || true)" if [ -z "$bin" ]; then warn "本机还没找到支持 --with-api-key 的 Codex CLI。配置已写入;打开 App 时不要点 ChatGPT 登录、不要填手机号。" return 0 fi step "向 Codex 注册 API 登录态…" if printf '%s' "$key" | CODEX_HOME="$CONFIG_DIR" "$bin" login --with-api-key >/dev/null 2>&1; then ok "已注册 API 登录(不要再填手机号)" else warn "codex login --with-api-key 未成功,配置文件已写入。请完全退出 App 后重开。" fi } quit_codex_apps step "清洗旧 provider 并注入…" run_py inject "$API_KEY" "$BASE_URL" register_api_login "$API_KEY" ok "完成" cat <