Created
March 4, 2026 10:02
-
-
Save fuzzbuster/a06abb96d626ef03a67e929e6f73c996 to your computer and use it in GitHub Desktop.
Quickly setup Codex and Claude Code on macOS or Debian based Linux
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 | |
| # | |
| # macos_ai_bootstrap.sh - Claude Code + Codex 一鍵安裝與配置腳本(macOS) | |
| # | |
| # 目標: | |
| # 1) 安裝 claude-code / codex(優先 Homebrew cask) | |
| # 2) 配置 API Key 與可選 Base URL | |
| # 3) 為 Codex 生成可管理的自訂 provider/profile | |
| # | |
| # 設計原則:可重複執行、盡量幂等、不覆蓋使用者既有未託管配置 | |
| # | |
| # 參考項目: | |
| # - Claude Code (official): https://github.com/anthropics/claude-code | |
| # - Codex CLI (official): https://github.com/openai/codex | |
| # - Community reference: https://github.com/PatchedReality/ai-cli-switchboard | |
| set -euo pipefail | |
| # ------------------------------------------------------------------------------ | |
| # 顏色與輸出 | |
| # ------------------------------------------------------------------------------ | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| NC='\033[0m' | |
| echo_info() { echo -e "${GREEN}[INFO]${NC} $1"; } | |
| echo_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } | |
| echo_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; exit 1; } | |
| # ------------------------------------------------------------------------------ | |
| # 全域預設值 | |
| # ------------------------------------------------------------------------------ | |
| NON_INTERACTIVE=false | |
| NO_INSTALL=false | |
| INSTALL_CLAUDE=true | |
| INSTALL_CODEX=true | |
| CONFIGURE_CLAUDE=true | |
| CONFIGURE_CODEX=true | |
| CLAUDE_API_KEY="${CLAUDE_API_KEY:-}" | |
| CLAUDE_BASE_URL="${CLAUDE_BASE_URL:-}" | |
| CODEX_API_KEY="${CODEX_API_KEY:-${OPENAI_API_KEY:-}}" | |
| CODEX_BASE_URL="${CODEX_BASE_URL:-}" | |
| CODEX_PROVIDER_ID="${CODEX_PROVIDER_ID:-ai_setup_openai_proxy}" | |
| CODEX_PROFILE_NAME="${CODEX_PROFILE_NAME:-ai_setup_openai_proxy}" | |
| CODEX_WIRE_API="${CODEX_WIRE_API:-responses}" | |
| SET_CODEX_PROFILE_DEFAULT=true | |
| WRITE_CLAUDE_SETTINGS=true | |
| WRITE_CODEX_AUTH_JSON=false | |
| ENABLE_BACKUP=true | |
| TARGET_SHELL="${BOOTSTRAP_SHELL:-auto}" | |
| AUTO_DETECT_KEYS=true | |
| INSTALL_PARSER_TOOLS=true | |
| SHELL_NAME="" | |
| SHELL_RC_FILE="" | |
| SHELL_PROFILE_FILE="" | |
| AI_ENV_FILE="${AI_ENV_FILE:-${HOME}/.config/ai-cli/env.sh}" | |
| CODEX_CONFIG_FILE="${HOME}/.codex/config.toml" | |
| CODEX_AUTH_FILE="${HOME}/.codex/auth.json" | |
| CLAUDE_SETTINGS_FILE="${HOME}/.claude/settings.json" | |
| BACKUP_ROOT_DIR="${HOME}/.ai-bootstrap-backups" | |
| RUN_TS="$(date +%Y%m%d_%H%M%S)" | |
| RUN_BACKUP_DIR="" | |
| CLAUDE_KEY_SOURCE="" | |
| CODEX_KEY_SOURCE="" | |
| HAVE_BREW=false | |
| BREW_BIN="" | |
| PLATFORM="" | |
| IS_DEBIAN=false | |
| APT_UPDATED=false | |
| SUDO_CMD="" | |
| # ------------------------------------------------------------------------------ | |
| # 幫助 | |
| # ------------------------------------------------------------------------------ | |
| usage() { | |
| cat <<'USAGE' | |
| Usage: bash macos_ai_bootstrap.sh [options] | |
| Options: | |
| --claude-api-key, --cak, -A <key> Set ANTHROPIC_API_KEY | |
| --claude-base-url, --cbu, -U <url> Set ANTHROPIC_BASE_URL | |
| --codex-api-key, --oak, -K <key> Set OPENAI_API_KEY | |
| --codex-base-url, --obu, -B <url> Configure Codex custom provider base_url | |
| --codex-wire-api, --cwa, -W <responses|chat> Codex provider wire API | |
| --codex-provider-id, --cpi, -I <id> Codex custom provider id | |
| --codex-profile, --cpf, -P <name> Codex profile name | |
| --no-codex-default-profile Do not set Codex default profile in config.toml | |
| --write-codex-auth-json, --wca, -a Write ~/.codex/auth.json with OPENAI_API_KEY | |
| --no-claude-settings Skip writing ~/.claude/settings.json | |
| --no-auto-detect-keys Disable migration auto-detect from existing files | |
| --skip-parser-tools Do not auto-install jq/yq helper tools | |
| --shell, -s <auto|zsh|bash> Select target shell rc/profile (default: auto) | |
| --skip-install, --si, -x Skip installation, only configure | |
| --skip-claude, --sc, -c Skip Claude install/config | |
| --skip-codex, --sx, -d Skip Codex install/config | |
| --no-backup, --nb Disable backup before file changes | |
| --backup-dir, --bd <path> Backup root dir (default: ~/.ai-bootstrap-backups) | |
| --non-interactive, --ni, -n Do not prompt, only use args/env | |
| -h, --help Show this help | |
| Examples: | |
| # Short flags | |
| bash macos_ai_bootstrap.sh -A 'sk-ant-xxx' -K 'sk-proj-xxx' -B 'https://api.openai.com/v1' -n | |
| # Long flags | |
| bash macos_ai_bootstrap.sh \ | |
| --claude-api-key 'sk-ant-xxx' \ | |
| --claude-base-url 'https://your-gateway.example.com' \ | |
| --codex-api-key 'sk-proj-xxx' \ | |
| --codex-base-url 'https://api.openai.com/v1' | |
| bash macos_ai_bootstrap.sh --skip-install --non-interactive \ | |
| --codex-base-url 'https://us.api.openai.com/v1' | |
| USAGE | |
| } | |
| # ------------------------------------------------------------------------------ | |
| # 參數解析 | |
| # ------------------------------------------------------------------------------ | |
| require_arg() { | |
| local name="$1" | |
| local value="${2:-}" | |
| [[ -n "$value" ]] || echo_error "Missing value for $name" | |
| } | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --claude-api-key|--cak|-A) | |
| require_arg "$1" "${2:-}" | |
| CLAUDE_API_KEY="$2" | |
| shift 2 | |
| ;; | |
| --claude-base-url|--cbu|-U) | |
| require_arg "$1" "${2:-}" | |
| CLAUDE_BASE_URL="$2" | |
| shift 2 | |
| ;; | |
| --codex-api-key|--oak|-K) | |
| require_arg "$1" "${2:-}" | |
| CODEX_API_KEY="$2" | |
| shift 2 | |
| ;; | |
| --codex-base-url|--obu|-B) | |
| require_arg "$1" "${2:-}" | |
| CODEX_BASE_URL="$2" | |
| shift 2 | |
| ;; | |
| --codex-wire-api|--cwa|-W) | |
| require_arg "$1" "${2:-}" | |
| CODEX_WIRE_API="$2" | |
| shift 2 | |
| ;; | |
| --codex-provider-id|--cpi|-I) | |
| require_arg "$1" "${2:-}" | |
| CODEX_PROVIDER_ID="$2" | |
| shift 2 | |
| ;; | |
| --codex-profile|--cpf|-P) | |
| require_arg "$1" "${2:-}" | |
| CODEX_PROFILE_NAME="$2" | |
| shift 2 | |
| ;; | |
| --no-codex-default-profile) | |
| SET_CODEX_PROFILE_DEFAULT=false | |
| shift | |
| ;; | |
| --write-codex-auth-json|--wca|-a) | |
| WRITE_CODEX_AUTH_JSON=true | |
| shift | |
| ;; | |
| --no-claude-settings) | |
| WRITE_CLAUDE_SETTINGS=false | |
| shift | |
| ;; | |
| --no-auto-detect-keys) | |
| AUTO_DETECT_KEYS=false | |
| shift | |
| ;; | |
| --skip-parser-tools) | |
| INSTALL_PARSER_TOOLS=false | |
| shift | |
| ;; | |
| --shell|-s) | |
| require_arg "$1" "${2:-}" | |
| TARGET_SHELL="$2" | |
| shift 2 | |
| ;; | |
| --skip-install|--si|-x) | |
| NO_INSTALL=true | |
| shift | |
| ;; | |
| --skip-claude|--sc|-c) | |
| INSTALL_CLAUDE=false | |
| CONFIGURE_CLAUDE=false | |
| shift | |
| ;; | |
| --skip-codex|--sx|-d) | |
| INSTALL_CODEX=false | |
| CONFIGURE_CODEX=false | |
| shift | |
| ;; | |
| --non-interactive|--ni|-n) | |
| NON_INTERACTIVE=true | |
| shift | |
| ;; | |
| --no-backup|--nb) | |
| ENABLE_BACKUP=false | |
| shift | |
| ;; | |
| --backup-dir|--bd) | |
| require_arg "$1" "${2:-}" | |
| BACKUP_ROOT_DIR="$2" | |
| shift 2 | |
| ;; | |
| -h|--help) | |
| usage | |
| exit 0 | |
| ;; | |
| *) | |
| echo_error "Unknown option: $1" | |
| ;; | |
| esac | |
| done | |
| if [[ ! "$CODEX_PROVIDER_ID" =~ ^[A-Za-z0-9_]+$ ]]; then | |
| echo_error "--codex-provider-id only supports letters, numbers, underscore" | |
| fi | |
| if [[ ! "$CODEX_PROFILE_NAME" =~ ^[A-Za-z0-9_]+$ ]]; then | |
| echo_error "--codex-profile only supports letters, numbers, underscore" | |
| fi | |
| if [[ "$TARGET_SHELL" != "auto" && "$TARGET_SHELL" != "zsh" && "$TARGET_SHELL" != "bash" ]]; then | |
| echo_error "--shell must be one of: auto, zsh, bash" | |
| fi | |
| # ------------------------------------------------------------------------------ | |
| # 基礎工具函式 | |
| # ------------------------------------------------------------------------------ | |
| ensure_dir() { | |
| local dir="$1" | |
| [[ -d "$dir" ]] || mkdir -p "$dir" | |
| } | |
| detect_platform() { | |
| local uname_out | |
| uname_out="$(uname -s)" | |
| case "$uname_out" in | |
| Darwin) | |
| PLATFORM="macos" | |
| ;; | |
| Linux) | |
| PLATFORM="linux" | |
| if [[ -f /etc/debian_version ]]; then | |
| IS_DEBIAN=true | |
| else | |
| echo_error "Linux support currently targets Debian/Ubuntu only" | |
| fi | |
| ;; | |
| *) | |
| echo_error "Unsupported platform: ${uname_out}" | |
| ;; | |
| esac | |
| } | |
| resolve_sudo_cmd() { | |
| if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then | |
| SUDO_CMD="" | |
| return 0 | |
| fi | |
| if command -v sudo >/dev/null 2>&1; then | |
| SUDO_CMD="sudo" | |
| return 0 | |
| fi | |
| SUDO_CMD="" | |
| } | |
| apt_update_once() { | |
| [[ "$IS_DEBIAN" == true ]] || return 0 | |
| [[ "$APT_UPDATED" == true ]] && return 0 | |
| if [[ -n "$SUDO_CMD" ]]; then | |
| DEBIAN_FRONTEND=noninteractive "$SUDO_CMD" apt-get update -y | |
| else | |
| DEBIAN_FRONTEND=noninteractive apt-get update -y | |
| fi | |
| APT_UPDATED=true | |
| } | |
| apt_install_packages() { | |
| [[ "$IS_DEBIAN" == true ]] || return 0 | |
| [[ $# -gt 0 ]] || return 0 | |
| apt_update_once | |
| if [[ -n "$SUDO_CMD" ]]; then | |
| DEBIAN_FRONTEND=noninteractive "$SUDO_CMD" apt-get install -y "$@" | |
| else | |
| DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" | |
| fi | |
| } | |
| ensure_debian_pkg_for_cmd() { | |
| local cmd="$1" | |
| shift | |
| if command -v "$cmd" >/dev/null 2>&1; then | |
| return 0 | |
| fi | |
| if [[ "$IS_DEBIAN" != true ]]; then | |
| return 0 | |
| fi | |
| if [[ "${EUID:-$(id -u)}" -ne 0 && -z "$SUDO_CMD" ]]; then | |
| echo_warn "Cannot install $cmd automatically (need root or sudo)" | |
| return 0 | |
| fi | |
| echo_info "Installing missing dependency: $cmd ($*)" | |
| apt_install_packages "$@" || echo_warn "Failed to install package(s): $*" | |
| } | |
| resolve_shell_files() { | |
| local detected | |
| if [[ "$TARGET_SHELL" == "auto" ]]; then | |
| detected="$(basename "${SHELL:-}")" | |
| else | |
| detected="$TARGET_SHELL" | |
| fi | |
| case "$detected" in | |
| zsh) | |
| SHELL_NAME="zsh" | |
| SHELL_RC_FILE="${HOME}/.zshrc" | |
| SHELL_PROFILE_FILE="${HOME}/.zprofile" | |
| ;; | |
| bash) | |
| SHELL_NAME="bash" | |
| SHELL_RC_FILE="${HOME}/.bashrc" | |
| SHELL_PROFILE_FILE="${HOME}/.bash_profile" | |
| ;; | |
| *) | |
| SHELL_NAME="zsh" | |
| SHELL_RC_FILE="${HOME}/.zshrc" | |
| SHELL_PROFILE_FILE="${HOME}/.zprofile" | |
| echo_warn "Unknown shell '$detected', fallback to zsh rc/profile" | |
| ;; | |
| esac | |
| } | |
| ensure_shell_rc_block() { | |
| local marker="$1" | |
| local content="$2" | |
| touch "$SHELL_RC_FILE" | |
| if ! grep -qF "$marker" "$SHELL_RC_FILE" 2>/dev/null; then | |
| echo_info "Appending block to $SHELL_RC_FILE: $marker" | |
| cat <<BLOCK >>"$SHELL_RC_FILE" | |
| $content | |
| BLOCK | |
| fi | |
| } | |
| init_backup_dir() { | |
| if [[ "$ENABLE_BACKUP" != true ]]; then | |
| return 0 | |
| fi | |
| if [[ -z "$RUN_BACKUP_DIR" ]]; then | |
| RUN_BACKUP_DIR="${BACKUP_ROOT_DIR}/${RUN_TS}" | |
| mkdir -p "$RUN_BACKUP_DIR" | |
| echo_info "Backup directory: $RUN_BACKUP_DIR" | |
| fi | |
| } | |
| backup_path_if_exists() { | |
| local path="$1" | |
| local label="$2" | |
| local sanitized target | |
| [[ "$ENABLE_BACKUP" == true ]] || return 0 | |
| [[ -e "$path" ]] || return 0 | |
| init_backup_dir | |
| sanitized="$(printf "%s" "$path" | sed 's#/#__#g')" | |
| target="${RUN_BACKUP_DIR}/${sanitized}" | |
| if [[ ! -e "$target" ]]; then | |
| cp -a "$path" "$target" | |
| echo_info "Backed up ${label}: $path -> $target" | |
| fi | |
| } | |
| json_escape() { | |
| # Minimal JSON escape for string values (portable on macOS/BSD awk). | |
| awk ' | |
| BEGIN { first = 1 } | |
| { | |
| gsub(/\\/, "\\\\") | |
| gsub(/"/, "\\\"") | |
| if (first == 0) printf "\\n" | |
| printf "%s", $0 | |
| first = 0 | |
| } | |
| ' <<<"$1" | |
| } | |
| shell_single_quote() { | |
| # 將任意字串包成可安全寫入 shell 的單引號字面值 | |
| local raw="$1" | |
| printf "'%s'" "$(printf "%s" "$raw" | sed "s/'/'\"'\"'/g")" | |
| } | |
| strip_wrapping_quotes() { | |
| local v="$1" | |
| local first last | |
| if (( ${#v} < 2 )); then | |
| printf "%s" "$v" | |
| return 0 | |
| fi | |
| first="${v:0:1}" | |
| last="${v: -1}" | |
| if [[ ( "$first" == "'" && "$last" == "'" ) || ( "$first" == '"' && "$last" == '"' ) ]]; then | |
| printf "%s" "${v:1:${#v}-2}" | |
| else | |
| printf "%s" "$v" | |
| fi | |
| } | |
| is_placeholder_value() { | |
| local value lowered | |
| value="$1" | |
| [[ -n "$value" ]] || return 0 | |
| lowered="$(printf "%s" "$value" | tr '[:upper:]' '[:lower:]')" | |
| case "$lowered" in | |
| x|xx|xxx|changeme|change_me|replace_me|your_key|your_api_key|your-api-key|api_key_here|sk-ant-xxx|sk-proj-xxx|sk-xxx) | |
| return 0 | |
| ;; | |
| esac | |
| return 1 | |
| } | |
| extract_shell_var_value() { | |
| local file="$1" key="$2" line value | |
| [[ -f "$file" ]] || return 1 | |
| line="$(awk -v k="$key" ' | |
| $0 ~ ("^[[:space:]]*(export[[:space:]]+)?" k "[[:space:]]*=") { found=$0 } | |
| END { if (found != "") print found } | |
| ' "$file")" | |
| [[ -n "$line" ]] || return 1 | |
| value="${line#*=}" | |
| value="$(printf "%s" "$value" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" | |
| value="$(strip_wrapping_quotes "$value")" | |
| [[ -n "$value" ]] || return 1 | |
| printf "%s" "$value" | |
| } | |
| extract_json_string_value() { | |
| local file="$1" key="$2" value | |
| [[ -f "$file" ]] || return 1 | |
| if command -v jq >/dev/null 2>&1; then | |
| value="$(jq -r --arg k "$key" 'first(.. | objects | .[$k]? | select(type == "string")) // empty' "$file" 2>/dev/null || true)" | |
| if [[ -n "$value" ]]; then | |
| printf "%s" "$value" | |
| return 0 | |
| fi | |
| fi | |
| awk -v k="$key" ' | |
| { | |
| pat = "\"" k "\"[[:space:]]*:[[:space:]]*\"" | |
| pos = match($0, pat) | |
| if (pos) { | |
| s = substr($0, RSTART + RLENGTH) | |
| end = index(s, "\"") | |
| if (end > 0) val = substr(s, 1, end - 1) | |
| } | |
| } | |
| END { | |
| if (val != "") print val | |
| else exit 1 | |
| } | |
| ' "$file" | |
| } | |
| extract_toml_string_value() { | |
| local file="$1" key="$2" line value | |
| [[ -f "$file" ]] || return 1 | |
| if command -v yq >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then | |
| if yq --help 2>/dev/null | grep -q "eval"; then | |
| value="$(yq eval -p=toml -o=json '.' "$file" 2>/dev/null | jq -r --arg k "$key" 'first(.. | objects | .[$k]? | select(type == "string")) // empty' 2>/dev/null || true)" | |
| if [[ -n "$value" ]]; then | |
| printf "%s" "$value" | |
| return 0 | |
| fi | |
| fi | |
| fi | |
| line="$(awk -v k="$key" ' | |
| $0 ~ ("^[[:space:]]*" k "[[:space:]]*=") { found=$0 } | |
| END { if (found != "") print found } | |
| ' "$file")" | |
| [[ -n "$line" ]] || return 1 | |
| value="${line#*=}" | |
| value="$(printf "%s" "$value" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" | |
| value="$(strip_wrapping_quotes "$value")" | |
| [[ -n "$value" ]] || return 1 | |
| printf "%s" "$value" | |
| } | |
| auto_detect_migration_values() { | |
| local v f | |
| [[ "$AUTO_DETECT_KEYS" == true ]] || return 0 | |
| if [[ -z "$CLAUDE_API_KEY" ]]; then | |
| for f in "$AI_ENV_FILE" "$HOME/.zsh/ai_cli.env" "$HOME/.config/ai-cli/env.sh"; do | |
| v="$(extract_shell_var_value "$f" "ANTHROPIC_API_KEY" 2>/dev/null || true)" | |
| if [[ -z "$v" ]]; then | |
| v="$(extract_shell_var_value "$f" "X_ANTHROPIC_API_KEY" 2>/dev/null || true)" | |
| fi | |
| if [[ -n "$v" ]] && ! is_placeholder_value "$v"; then | |
| CLAUDE_API_KEY="$v" | |
| CLAUDE_KEY_SOURCE="$f" | |
| break | |
| fi | |
| done | |
| fi | |
| if [[ -z "$CLAUDE_API_KEY" ]]; then | |
| for f in "$CLAUDE_SETTINGS_FILE" "$HOME/.claude/settings.json" "$HOME/.claude.json"; do | |
| v="$(extract_json_string_value "$f" "ANTHROPIC_API_KEY" 2>/dev/null || true)" | |
| if [[ -z "$v" ]]; then | |
| v="$(extract_json_string_value "$f" "X_ANTHROPIC_API_KEY" 2>/dev/null || true)" | |
| fi | |
| if [[ -n "$v" ]] && ! is_placeholder_value "$v"; then | |
| CLAUDE_API_KEY="$v" | |
| CLAUDE_KEY_SOURCE="$f" | |
| break | |
| fi | |
| done | |
| fi | |
| if [[ -z "$CLAUDE_BASE_URL" ]]; then | |
| for f in "$AI_ENV_FILE" "$HOME/.zsh/ai_cli.env" "$HOME/.config/ai-cli/env.sh"; do | |
| v="$(extract_shell_var_value "$f" "ANTHROPIC_BASE_URL" 2>/dev/null || true)" | |
| if [[ -n "$v" ]] && ! is_placeholder_value "$v"; then | |
| CLAUDE_BASE_URL="$v" | |
| break | |
| fi | |
| done | |
| fi | |
| if [[ -z "$CLAUDE_BASE_URL" ]]; then | |
| for f in "$CLAUDE_SETTINGS_FILE" "$HOME/.claude/settings.json"; do | |
| v="$(extract_json_string_value "$f" "ANTHROPIC_BASE_URL" 2>/dev/null || true)" | |
| if [[ -n "$v" ]] && ! is_placeholder_value "$v"; then | |
| CLAUDE_BASE_URL="$v" | |
| break | |
| fi | |
| done | |
| fi | |
| if [[ -z "$CODEX_API_KEY" ]]; then | |
| for f in "$AI_ENV_FILE" "$HOME/.zsh/ai_cli.env" "$HOME/.config/ai-cli/env.sh"; do | |
| v="$(extract_shell_var_value "$f" "OPENAI_API_KEY" 2>/dev/null || true)" | |
| if [[ -n "$v" ]] && ! is_placeholder_value "$v"; then | |
| CODEX_API_KEY="$v" | |
| CODEX_KEY_SOURCE="$f" | |
| break | |
| fi | |
| done | |
| fi | |
| if [[ -z "$CODEX_API_KEY" ]]; then | |
| v="$(extract_json_string_value "$CODEX_AUTH_FILE" "OPENAI_API_KEY" 2>/dev/null || true)" | |
| if [[ -n "$v" ]] && ! is_placeholder_value "$v"; then | |
| CODEX_API_KEY="$v" | |
| CODEX_KEY_SOURCE="$CODEX_AUTH_FILE" | |
| fi | |
| fi | |
| if [[ -z "$CODEX_BASE_URL" ]]; then | |
| v="$(extract_toml_string_value "$CODEX_CONFIG_FILE" "base_url" 2>/dev/null || true)" | |
| if [[ -n "$v" ]] && ! is_placeholder_value "$v"; then | |
| CODEX_BASE_URL="$v" | |
| fi | |
| fi | |
| if [[ -n "$CLAUDE_KEY_SOURCE" ]]; then | |
| echo_info "Auto-detected Claude key from $CLAUDE_KEY_SOURCE" | |
| fi | |
| if [[ -n "$CODEX_KEY_SOURCE" ]]; then | |
| echo_info "Auto-detected Codex key from $CODEX_KEY_SOURCE" | |
| fi | |
| } | |
| upsert_export() { | |
| local file="$1" key="$2" value="$3" | |
| local quoted line tmp found | |
| [[ -n "$value" ]] || return 0 | |
| quoted="$(shell_single_quote "$value")" | |
| line="export ${key}=${quoted}" | |
| ensure_dir "$(dirname "$file")" | |
| touch "$file" | |
| chmod 600 "$file" || true | |
| tmp="$(mktemp)" | |
| found=false | |
| while IFS= read -r current || [[ -n "$current" ]]; do | |
| if [[ "$current" =~ ^export[[:space:]]+${key}= ]]; then | |
| if [[ "$found" == false ]]; then | |
| printf "%s\n" "$line" >>"$tmp" | |
| found=true | |
| fi | |
| else | |
| printf "%s\n" "$current" >>"$tmp" | |
| fi | |
| done <"$file" | |
| if [[ "$found" == false ]]; then | |
| printf "%s\n" "$line" >>"$tmp" | |
| fi | |
| mv "$tmp" "$file" | |
| chmod 600 "$file" || true | |
| echo_info "Updated $key in $file" | |
| } | |
| remove_marked_block() { | |
| local file="$1" start_marker="$2" end_marker="$3" | |
| local tmp | |
| [[ -f "$file" ]] || return 0 | |
| tmp="$(mktemp)" | |
| awk -v s="$start_marker" -v e="$end_marker" ' | |
| $0 == s { skipping=1; next } | |
| $0 == e { skipping=0; next } | |
| !skipping { print } | |
| ' "$file" >"$tmp" | |
| mv "$tmp" "$file" | |
| } | |
| append_managed_block() { | |
| local file="$1" start_marker="$2" end_marker="$3" content="$4" | |
| local tmp | |
| ensure_dir "$(dirname "$file")" | |
| touch "$file" | |
| remove_marked_block "$file" "$start_marker" "$end_marker" | |
| tmp="$(mktemp)" | |
| { | |
| if [[ -s "$file" ]]; then | |
| cat "$file" | |
| printf "\n" | |
| fi | |
| printf "%s\n" "$start_marker" | |
| printf "%s\n" "$content" | |
| printf "%s\n" "$end_marker" | |
| } >"$tmp" | |
| mv "$tmp" "$file" | |
| } | |
| prepend_managed_block() { | |
| local file="$1" start_marker="$2" end_marker="$3" content="$4" | |
| local tmp | |
| ensure_dir "$(dirname "$file")" | |
| touch "$file" | |
| remove_marked_block "$file" "$start_marker" "$end_marker" | |
| tmp="$(mktemp)" | |
| { | |
| printf "%s\n" "$start_marker" | |
| printf "%s\n" "$content" | |
| printf "%s\n" "$end_marker" | |
| printf "\n" | |
| cat "$file" | |
| } >"$tmp" | |
| mv "$tmp" "$file" | |
| } | |
| has_root_profile_key() { | |
| local file="$1" | |
| [[ -f "$file" ]] || return 1 | |
| awk ' | |
| /^[[:space:]]*#/ { next } | |
| /^[[:space:]]*$/ { next } | |
| /^[[:space:]]*\[/ { exit(found ? 0 : 1) } | |
| /^[[:space:]]*profile[[:space:]]*=/ { found=1; exit 0 } | |
| END { exit(found ? 0 : 1) } | |
| ' "$file" | |
| } | |
| prompt_if_empty() { | |
| local var_name="$1" message="$2" secret="${3:-false}" | |
| local value="${!var_name:-}" | |
| [[ -n "$value" ]] && return 0 | |
| [[ "$NON_INTERACTIVE" == true ]] && return 0 | |
| if [[ "$secret" == true ]]; then | |
| read -r -s -p "$message" value | |
| echo | |
| else | |
| read -r -p "$message" value | |
| fi | |
| printf -v "$var_name" '%s' "$value" | |
| } | |
| # ------------------------------------------------------------------------------ | |
| # Homebrew / 安裝 | |
| # ------------------------------------------------------------------------------ | |
| ensure_homebrew() { | |
| local brew_candidate="" | |
| if [[ "$PLATFORM" != "macos" ]]; then | |
| if command -v brew &>/dev/null; then | |
| BREW_BIN="$(command -v brew)" | |
| HAVE_BREW=true | |
| echo_info "Homebrew detected on Linux: ${BREW_BIN}" | |
| else | |
| HAVE_BREW=false | |
| echo_info "Homebrew not required on ${PLATFORM}" | |
| fi | |
| return 0 | |
| fi | |
| if command -v brew &>/dev/null; then | |
| BREW_BIN="$(command -v brew)" | |
| else | |
| [[ -x "/opt/homebrew/bin/brew" ]] && brew_candidate="/opt/homebrew/bin/brew" | |
| [[ -z "$brew_candidate" && -x "/usr/local/bin/brew" ]] && brew_candidate="/usr/local/bin/brew" | |
| [[ -n "$brew_candidate" ]] && BREW_BIN="$brew_candidate" | |
| fi | |
| if [[ -n "$BREW_BIN" ]]; then | |
| touch "$SHELL_PROFILE_FILE" | |
| local shellenv_line | |
| shellenv_line="eval \"\$(${BREW_BIN} shellenv)\"" | |
| grep -qxF "$shellenv_line" "$SHELL_PROFILE_FILE" || printf "%s\n" "$shellenv_line" >>"$SHELL_PROFILE_FILE" | |
| eval "$($BREW_BIN shellenv)" | |
| HAVE_BREW=true | |
| echo_info "Homebrew is ready." | |
| else | |
| HAVE_BREW=false | |
| echo_warn "Homebrew not found; skipping Claude/Codex brew install" | |
| fi | |
| } | |
| ensure_brew_pkg() { | |
| local pkg="$1" | |
| if [[ "$HAVE_BREW" != true ]]; then | |
| return 0 | |
| fi | |
| if ! brew list --formula "$pkg" >/dev/null 2>&1; then | |
| echo_info "Installing brew package: $pkg" | |
| brew install "$pkg" || echo_warn "Failed to install brew package: $pkg" | |
| fi | |
| } | |
| ensure_brew_cask() { | |
| local cask="$1" | |
| if [[ "$HAVE_BREW" != true ]]; then | |
| echo_warn "brew unavailable, skip cask: $cask" | |
| return 0 | |
| fi | |
| if ! brew list --cask "$cask" >/dev/null 2>&1; then | |
| echo_info "Installing cask: $cask" | |
| brew install --cask "$cask" || echo_warn "Failed to install cask: $cask" | |
| else | |
| echo_info "Cask already installed: $cask" | |
| fi | |
| } | |
| ensure_parser_tools() { | |
| [[ "$INSTALL_PARSER_TOOLS" == true ]] || return 0 | |
| if [[ "$PLATFORM" == "macos" ]]; then | |
| if [[ "$HAVE_BREW" == true ]]; then | |
| ensure_brew_pkg jq | |
| ensure_brew_pkg yq | |
| else | |
| echo_warn "Skip parser tools install on macOS because brew is unavailable" | |
| fi | |
| return 0 | |
| fi | |
| if [[ "$IS_DEBIAN" == true ]]; then | |
| ensure_debian_pkg_for_cmd jq jq | |
| ensure_debian_pkg_for_cmd yq yq | |
| return 0 | |
| fi | |
| } | |
| ensure_npm_available() { | |
| if command -v npm >/dev/null 2>&1; then | |
| return 0 | |
| fi | |
| if [[ "$IS_DEBIAN" == true ]]; then | |
| ensure_debian_pkg_for_cmd nodejs nodejs | |
| ensure_debian_pkg_for_cmd npm npm | |
| fi | |
| } | |
| ensure_claude_installed() { | |
| if command -v claude >/dev/null 2>&1; then | |
| echo_info "Claude Code already installed" | |
| return 0 | |
| fi | |
| if [[ "$PLATFORM" == "macos" ]]; then | |
| ensure_brew_cask "claude-code" | |
| fi | |
| if ! command -v claude >/dev/null 2>&1 && [[ "$IS_DEBIAN" == true ]]; then | |
| ensure_debian_pkg_for_cmd curl curl ca-certificates | |
| fi | |
| if ! command -v claude >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then | |
| echo_warn "Trying official Claude installer via curl" | |
| curl -fsSL https://claude.ai/install.sh | bash || echo_warn "Official Claude installer failed" | |
| fi | |
| } | |
| ensure_codex_installed() { | |
| if command -v codex >/dev/null 2>&1; then | |
| echo_info "Codex already installed" | |
| return 0 | |
| fi | |
| if [[ "$PLATFORM" == "macos" ]]; then | |
| ensure_brew_cask "codex" | |
| fi | |
| if ! command -v codex >/dev/null 2>&1; then | |
| ensure_npm_available | |
| fi | |
| if ! command -v codex >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then | |
| echo_warn "Trying npm install for Codex" | |
| if ! npm install -g @openai/codex; then | |
| if [[ -n "$SUDO_CMD" ]]; then | |
| "$SUDO_CMD" npm install -g @openai/codex || echo_warn "npm install @openai/codex failed" | |
| else | |
| echo_warn "npm install @openai/codex failed" | |
| fi | |
| fi | |
| fi | |
| } | |
| # ------------------------------------------------------------------------------ | |
| # 配置 | |
| # ------------------------------------------------------------------------------ | |
| configure_shell_env() { | |
| local loader_marker="# Load AI CLI env (Claude/Codex)" | |
| local loader_block | |
| loader_block=$(cat <<BLOCK | |
| # Load AI CLI env (Claude/Codex) | |
| [[ -f "$AI_ENV_FILE" ]] && source "$AI_ENV_FILE" | |
| BLOCK | |
| ) | |
| backup_path_if_exists "$SHELL_RC_FILE" "${SHELL_NAME} rc file" | |
| backup_path_if_exists "$AI_ENV_FILE" "AI env file" | |
| ensure_dir "$(dirname "$AI_ENV_FILE")" | |
| touch "$AI_ENV_FILE" | |
| chmod 600 "$AI_ENV_FILE" || true | |
| ensure_shell_rc_block "$loader_marker" "$loader_block" | |
| if [[ -n "$CLAUDE_API_KEY" ]]; then | |
| if [[ "$CONFIGURE_CLAUDE" == true ]]; then | |
| upsert_export "$AI_ENV_FILE" "ANTHROPIC_API_KEY" "$CLAUDE_API_KEY" | |
| fi | |
| fi | |
| if [[ -n "$CLAUDE_BASE_URL" ]]; then | |
| if [[ "$CONFIGURE_CLAUDE" == true ]]; then | |
| # 注意:此變數常用於相容 gateway;未在 Claude Code 最新設定頁明確列為通用變數。 | |
| upsert_export "$AI_ENV_FILE" "ANTHROPIC_BASE_URL" "$CLAUDE_BASE_URL" | |
| echo_warn "Configured ANTHROPIC_BASE_URL for Claude-compatible gateway usage" | |
| fi | |
| fi | |
| if [[ -n "$CODEX_API_KEY" ]]; then | |
| if [[ "$CONFIGURE_CODEX" == true ]]; then | |
| upsert_export "$AI_ENV_FILE" "OPENAI_API_KEY" "$CODEX_API_KEY" | |
| fi | |
| fi | |
| } | |
| configure_claude_settings_file() { | |
| local f="$CLAUDE_SETTINGS_FILE" | |
| local api_key_escaped base_url_escaped | |
| if [[ "$CONFIGURE_CLAUDE" != true || "$WRITE_CLAUDE_SETTINGS" != true ]]; then | |
| return 0 | |
| fi | |
| if [[ -z "$CLAUDE_API_KEY" && -z "$CLAUDE_BASE_URL" ]]; then | |
| echo_info "Claude key/base_url not provided, skip writing $f" | |
| return 0 | |
| fi | |
| ensure_dir "$(dirname "$f")" | |
| backup_path_if_exists "$f" "Claude settings.json" | |
| api_key_escaped="$(json_escape "$CLAUDE_API_KEY")" | |
| base_url_escaped="$(json_escape "$CLAUDE_BASE_URL")" | |
| cat >"$f" <<EOF | |
| { | |
| "env": { | |
| "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", | |
| "CLAUDE_CODE_ENABLE_TELEMETRY": "0", | |
| "DISABLE_ERROR_REPORTING": "1", | |
| "DISABLE_BUG_COMMAND": "1", | |
| "DISABLE_TELEMETRY": "1", | |
| "ANTHROPIC_API_KEY": "${api_key_escaped}", | |
| "X_ANTHROPIC_API_KEY": "${api_key_escaped}", | |
| "ANTHROPIC_BASE_URL": "${base_url_escaped}" | |
| }, | |
| "skipWebFetchPreflight": true | |
| } | |
| EOF | |
| chmod 600 "$f" || true | |
| echo_info "Updated Claude settings file: $f" | |
| } | |
| configure_codex_auth_file() { | |
| local f="$CODEX_AUTH_FILE" | |
| local api_key_escaped | |
| if [[ "$CONFIGURE_CODEX" != true || "$WRITE_CODEX_AUTH_JSON" != true ]]; then | |
| return 0 | |
| fi | |
| if [[ -z "$CODEX_API_KEY" ]]; then | |
| echo_warn "Skipping ~/.codex/auth.json because OPENAI_API_KEY is empty" | |
| return 0 | |
| fi | |
| ensure_dir "$(dirname "$f")" | |
| backup_path_if_exists "$f" "Codex auth.json" | |
| api_key_escaped="$(json_escape "$CODEX_API_KEY")" | |
| cat >"$f" <<EOF | |
| { | |
| "OPENAI_API_KEY": "${api_key_escaped}" | |
| } | |
| EOF | |
| chmod 600 "$f" || true | |
| echo_info "Updated Codex auth file: $f" | |
| } | |
| configure_codex_provider() { | |
| local cfg="$CODEX_CONFIG_FILE" | |
| local provider_block_start="# >>> ai-agent-setup codex provider >>>" | |
| local provider_block_end="# <<< ai-agent-setup codex provider <<<" | |
| local profile_block_start="# >>> ai-agent-setup codex default profile >>>" | |
| local profile_block_end="# <<< ai-agent-setup codex default profile <<<" | |
| [[ -n "$CODEX_BASE_URL" ]] || { | |
| echo_info "CODEX_BASE_URL not set, skip Codex provider configuration" | |
| return 0 | |
| } | |
| if [[ "$CODEX_WIRE_API" != "responses" && "$CODEX_WIRE_API" != "chat" ]]; then | |
| echo_error "--codex-wire-api must be 'responses' or 'chat'" | |
| fi | |
| ensure_dir "$(dirname "$cfg")" | |
| backup_path_if_exists "$cfg" "Codex config.toml" | |
| touch "$cfg" | |
| local provider_block | |
| provider_block=$(cat <<TOML | |
| [model_providers.${CODEX_PROVIDER_ID}] | |
| name = "AI Agent Setup Proxy" | |
| base_url = "${CODEX_BASE_URL}" | |
| env_key = "OPENAI_API_KEY" | |
| wire_api = "${CODEX_WIRE_API}" | |
| [profiles.${CODEX_PROFILE_NAME}] | |
| model_provider = "${CODEX_PROVIDER_ID}" | |
| TOML | |
| ) | |
| append_managed_block "$cfg" "$provider_block_start" "$provider_block_end" "$provider_block" | |
| echo_info "Updated Codex provider/profile block in $cfg" | |
| if [[ "$SET_CODEX_PROFILE_DEFAULT" == true ]]; then | |
| if has_root_profile_key "$cfg"; then | |
| echo_warn "Detected existing top-level 'profile' in $cfg; keep existing value" | |
| echo_warn "If needed, run: codex --profile ${CODEX_PROFILE_NAME}" | |
| else | |
| prepend_managed_block "$cfg" "$profile_block_start" "$profile_block_end" "profile = \"${CODEX_PROFILE_NAME}\"" | |
| echo_info "Set Codex default profile to ${CODEX_PROFILE_NAME}" | |
| fi | |
| else | |
| echo_info "Default profile not changed. Use: codex --profile ${CODEX_PROFILE_NAME}" | |
| fi | |
| } | |
| # ------------------------------------------------------------------------------ | |
| # 主流程 | |
| # ------------------------------------------------------------------------------ | |
| echo_info "Starting AI CLI bootstrap (Claude Code + Codex)..." | |
| detect_platform | |
| resolve_sudo_cmd | |
| resolve_shell_files | |
| echo_info "Platform: ${PLATFORM}" | |
| echo_info "Target shell: ${SHELL_NAME} (rc: ${SHELL_RC_FILE}, profile: ${SHELL_PROFILE_FILE})" | |
| auto_detect_migration_values | |
| if [[ "$NON_INTERACTIVE" != true ]]; then | |
| if [[ "$CONFIGURE_CLAUDE" == true ]]; then | |
| prompt_if_empty CLAUDE_API_KEY "Enter Claude API key (ANTHROPIC_API_KEY, leave blank to skip): " true | |
| prompt_if_empty CLAUDE_BASE_URL "Enter Claude base URL (optional, leave blank to skip): " false | |
| fi | |
| if [[ "$CONFIGURE_CODEX" == true ]]; then | |
| prompt_if_empty CODEX_API_KEY "Enter Codex/OpenAI API key (OPENAI_API_KEY, leave blank to skip): " true | |
| prompt_if_empty CODEX_BASE_URL "Enter Codex custom base URL (optional, leave blank to skip): " false | |
| fi | |
| fi | |
| if [[ "$NO_INSTALL" != true ]]; then | |
| ensure_homebrew | |
| ensure_parser_tools | |
| if [[ "$INSTALL_CLAUDE" == true ]]; then | |
| ensure_claude_installed | |
| fi | |
| if [[ "$INSTALL_CODEX" == true ]]; then | |
| ensure_codex_installed | |
| fi | |
| else | |
| echo_info "Skipping installation by --skip-install" | |
| fi | |
| configure_shell_env | |
| configure_claude_settings_file | |
| configure_codex_auth_file | |
| if [[ "$CONFIGURE_CODEX" == true ]]; then | |
| configure_codex_provider | |
| fi | |
| echo_info "Verifying installed binaries..." | |
| if command -v claude >/dev/null 2>&1; then | |
| echo_info "claude: $(claude --version 2>/dev/null || echo 'installed')" | |
| else | |
| echo_warn "claude not found in PATH" | |
| fi | |
| if command -v codex >/dev/null 2>&1; then | |
| echo_info "codex: $(codex --version 2>/dev/null || echo 'installed')" | |
| else | |
| echo_warn "codex not found in PATH" | |
| fi | |
| echo_info "Done. Reload shell with: source ${SHELL_RC_FILE}" | |
| if [[ -n "$CODEX_BASE_URL" ]]; then | |
| echo_info "Codex active profile: ${CODEX_PROFILE_NAME}" | |
| fi | |
| echo_info "Auth summary:" | |
| if [[ -n "$CLAUDE_API_KEY" ]]; then | |
| echo_info "1) Claude API key configured. You can skip interactive login." | |
| else | |
| echo_info "1) Claude API key not set. Use /login or rerun with --cak." | |
| fi | |
| if [[ -n "$CODEX_API_KEY" ]]; then | |
| echo_info "2) Codex/OpenAI API key configured. You can skip ChatGPT login." | |
| else | |
| echo_info "2) Codex API key not set. Use login or rerun with --oak." | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment