#!/usr/bin/env bash # SCX.ai setup for coding tools: Claude Code, Codex and OpenCode. # # curl -fsSL https://scx-cli.word.tech | bash # set up or update # curl -fsSL https://scx-cli.word.tech | bash -s -- --remove # undo everything # # Every change is logged in ~/.scx/state and every file is backed up under # ~/.scx/backups first, so --remove puts each file back as it was. All code is # in functions and main runs on the last line, so a partial download does nothing. SCX_CLI_VERSION="0.1.0" SCX_URL="https://scx-cli.word.tech" API_BASE="${SCX_API_BASE:-https://api.scx.ai}" KEYS_URL="${SCX_KEYS_URL:-https://platform.scx.ai/api-keys}" DEFAULT_MAIN="coder" DEFAULT_FAST="MiniMax-M3" # Used only when the account's model list can't be read. # id, context window, max output, reasoning (1/0), image input (1/0) FALLBACK_MODELS="coder 196608 4096 1 0 MiniMax-M3 1048576 8192 1 1" # Oldest Codex verified to read the key through [model_providers..auth]; # older releases get env_key = "SCX_API_KEY" instead. CODEX_AUTH_MIN="0.163.0" TOOLS="claude codex opencode" ABSENT='"__SCX_ABSENT__"' # --- output ------------------------------------------------------------------- ui_init() { if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != dumb ]; then B=$'\033[1m' D=$'\033[2m' G=$'\033[32m' Y=$'\033[33m' R=$'\033[31m' C=$'\033[36m' N=$'\033[0m' else B="" D="" G="" Y="" R="" C="" N="" fi case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in *UTF-8* | *utf8* | *UTF8* | *utf-8*) TICK="✓" CROSS="✗" ELL="…" ;; *) TICK="ok" CROSS="x" ELL="..." ;; esac } say() { printf '%s\n' "$*"; } ok() { printf ' %s%s%s %s\n' "${G}" "$TICK" "${N}" "$*"; } bad() { printf ' %s%s%s %s\n' "${R}" "$CROSS" "${N}" "$*"; } warn() { printf ' %s!%s %s\n' "${Y}" "${N}" "$*"; } note() { printf ' %s%s%s\n' "${D}" "$*" "${N}"; } die() { printf '\n %s%s%s\n\n' "${R}" "$*" "${N}" >&2 exit 1 } tilde() { case $1 in "$HOME"/*) printf '~%s' "${1#"$HOME"}" ;; *) printf '%s' "$1" ;; esac } mask() { printf '%s%s%s' "${1:0:7}" "$ELL" "${1: -4}"; } usage() { cat < m ? n : m); i++) { if (x[i] + 0 > y[i] + 0) exit 0 if (x[i] + 0 < y[i] + 0) exit 1 } exit 0 }' } # Run a command, killing it after $1 seconds. with_timeout() { local secs=$1 pid watcher rc shift "$@" & pid=$! (sleep "$secs" && kill "$pid") >/dev/null 2>&1 & watcher=$! wait "$pid" rc=$? kill "$watcher" 2>/dev/null return $rc } canon() { if command -v realpath >/dev/null 2>&1 && realpath "$1" 2>/dev/null; then return 0; fi (cd -P "$(dirname "$1")" 2>/dev/null && printf '%s/%s\n' "$(pwd -P)" "$(basename "$1")") || printf '%s\n' "$1" } in_list() { case " $2 " in *" $1 "*) return 0 ;; esac; return 1; } # --- terminal input ------------------------------------------------------------- tty_init() { if [ -n "${SCX_TTY:-}" ]; then [ -r "$SCX_TTY" ] || die "Can't read answers from $SCX_TTY. Use --yes for a non-interactive setup." exec 3<"$SCX_TTY" elif [ "$YES" = 1 ]; then exec 3/dev/null; then exec 3 0; i--) { if (Object.keys(chain[i]).length) break; delete chain[i - 1][ks[i - 1]]; } save(d); } else if (op === "models") { (load().data || []).forEach(function (m) { var f = m.supported_features || [], o = m.output_modalities || ["text"], inp = m.input_modalities || []; if (f.indexOf("tools") < 0 || o.length !== 1 || o[0] !== "text") return; out.push([m.id, String(m.context_length || 0), String(m.max_output_length || 0), f.indexOf("reasoning") >= 0 ? "1" : "0", inp.indexOf("image") >= 0 ? "1" : "0"].join("\t")); }); } return out.join("\n"); } EOF } json_backend() { [ -n "${JSON_BE:-}" ] && return 0 JSON_BE=none case ${SCX_JSON:-} in python | node | jxa) JSON_BE=$SCX_JSON; return 0 ;; esac if command -v python3 >/dev/null 2>&1 && python_usable; then JSON_BE=python elif command -v node >/dev/null 2>&1; then JSON_BE=node elif [ "$(uname -s)" = Darwin ] && command -v osascript >/dev/null 2>&1; then JSON_BE=jxa fi } # On a Mac without the command line tools, /usr/bin/python3 is a stub that pops up an installer. python_usable() { if [ "$(uname -s)" = Darwin ] && [ "$(command -v python3)" = /usr/bin/python3 ] && ! xcode-select -p >/dev/null 2>&1; then return 1 fi python3 -I -c 'import json' >/dev/null 2>&1 } # json OP FILE [KEY.PATH] [VALUE]: check | get | set | del | models json() { json_backend case $JSON_BE in python) python3 -I -c "$(py_json)" "$@" ;; node) node -e "$(js_json) var fs = require('fs'); try { var r = scxMain(process.argv.slice(1), { read: function (p) { try { return fs.readFileSync(p, 'utf8'); } catch (e) { return null; } }, write: function (p, s) { fs.writeFileSync(p, s); } }); if (r) process.stdout.write(r + '\n'); } catch (e) { process.exit(e.scxCode || 3); }" "$@" ;; jxa) osascript -l JavaScript -e "$(js_json) ObjC.import('Foundation'); function run(argv) { return scxMain(argv, { read: function (p) { var s = ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError($(p), $.NSUTF8StringEncoding, null)); return (s === undefined || s === null) ? null : s; }, write: function (p, t) { $(t).writeToFileAtomicallyEncodingError($(p), false, $.NSUTF8StringEncoding, null); } }); }" "$@" ;; *) return 9 ;; esac } # --- recorded changes ----------------------------------------------------------- # ~/.scx/state is a tab-separated log: # touch FILE EXISTED BACKUP a file we changed (backed up first) # sum FILE CHECKSUM its checksum right after setup # json FILE KEY PREVIOUS a JSON key we set, and its value before # owned FILE a file we created outright # tool NAME MODE / models MAIN FAST state_add() { local IFS=$'\t' printf '%s\n' "$*" >>"$STATE" } touch_file() { local f=$1 b=- existed=0 case "$TOUCHED" in *"|$f|"*) return 0 ;; esac TOUCHED="$TOUCHED|$f|" mkdir -p "$BACKUP_DIR" if [ -e "$f" ]; then existed=1 b="$BACKUP_DIR/$(printf '%s' "$f" | sed 's#/#%#g')" cp -p "$f" "$b" fi state_add touch "$f" "$existed" "$b" } finalize_state() { local f rest=$TOUCHED while [ -n "$rest" ]; do rest=${rest#|} f=${rest%%|*} rest=${rest#*|} [ -n "$f" ] && [ -e "$f" ] && state_add sum "$f" "$(fsum "$f")" done } # Write a file that SCX owns (a wrapper, a Codex profile). MODE is the chmod mode. write_owned() { local f=$1 content=$2 mode=$3 if [ "$DRY" = 1 ]; then say " would create $(tilde "$f")" return 0 fi mkdir -p "$(dirname "$f")" if [ -e "$f" ]; then touch_file "$f"; else state_add owned "$f"; fi printf '%s\n' "$content" >"$f" chmod "$mode" "$f" } j_set() { local f=$1 k=$2 v=$3 prev if [ "$DRY" = 1 ]; then say " would set $k in $(tilde "$f")" return 0 fi prev=$(json get "$f" "$k") || return 1 touch_file "$f" mkdir -p "$(dirname "$f")" json set "$f" "$k" "$v" || return 1 state_add json "$f" "$k" "$prev" } j_del() { local f=$1 k=$2 prev [ -f "$f" ] || return 0 prev=$(json get "$f" "$k") || return 1 [ "$prev" = "$ABSENT" ] && return 0 if [ "$DRY" = 1 ]; then say " would remove $k from $(tilde "$f")" return 0 fi touch_file "$f" json del "$f" "$k" || return 1 state_add json "$f" "$k" "$prev" } block() { printf '# >>> scx >>>\n# Added by SCX.ai setup. Undo: curl -fsSL %s | bash -s -- --remove\n%s\n# <<< scx <<<\n' "$SCX_URL" "$1" } block_append() { local f=$1 if [ "$DRY" = 1 ]; then say " would add a block to $(tilde "$f")" return 0 fi touch_file "$f" mkdir -p "$(dirname "$f")" if [ -s "$f" ] && [ -n "$(tail -c 1 "$f")" ]; then printf '\n' >>"$f"; fi block "$2" >>"$f" } block_prepend() { local f=$1 tmp="$TMP/prepend" if [ "$DRY" = 1 ]; then say " would add a block to the top of $(tilde "$f")" return 0 fi touch_file "$f" mkdir -p "$(dirname "$f")" { block "$2"; [ -f "$f" ] && cat "$f"; } >"$tmp" cat "$tmp" >"$f" } # awk_patch FILE WHAT AWK-ARGS...: rewrite FILE through awk if that changes anything. awk_patch() { local f=$1 what=$2 tmp="$TMP/awk_patch" shift 2 [ -f "$f" ] || return 0 awk "$@" "$f" >"$tmp" || return 1 cmp -s "$tmp" "$f" && return 0 if [ "$DRY" = 1 ]; then say " would comment out $what in $(tilde "$f")" return 0 fi touch_file "$f" cat "$tmp" >"$f" } # Remove our blocks and restore lines we commented out ("# scx: "). text_unpatch() { local f=$1 tmp="$TMP/unpatch" grep -qE '^# (>>> scx >>>|scx: )' "$f" 2>/dev/null || return 0 awk ' $0 == "# >>> scx >>>" { skip = 1; next } $0 == "# <<< scx <<<" { skip = 0; next } skip { next } /^# scx: / { print substr($0, 8); next } { print }' "$f" >"$tmp" && cat "$tmp" >"$f" } revert_all() { [ -f "$STATE" ] || return 0 local kind a b c sum while IFS=$'\t' read -r kind a b c; do [ "$kind" = owned ] && rm -f "$a" done <"$STATE" while IFS=$'\t' read -r kind a b c; do [ "$kind" = touch ] || continue sum=$(awk -F'\t' -v f="$a" '$1 == "sum" && $2 == f { s = $3 } END { print s }' "$STATE") if [ -e "$a" ] && [ -n "$sum" ] && [ "$(fsum "$a")" = "$sum" ]; then # Untouched since setup: put the original back byte for byte. if [ "$b" = 1 ]; then cat "$c" >"$a"; else rm -f "$a"; fi elif [ -e "$a" ]; then # Edited since setup: undo only our own changes. text_unpatch "$a" awk -F'\t' -v f="$a" '$1 == "json" && $2 == f' "$STATE" | awk '{ l[NR] = $0 } END { for (i = NR; i > 0; i--) print l[i] }' | while IFS=$'\t' read -r kind file key prev; do if [ "$prev" = "$ABSENT" ]; then json del "$file" "$key"; else json set "$file" "$key" "$prev"; fi || warn "Couldn't restore $key in $(tilde "$file")" done if [ "$b" = 0 ] && [ -z "$(tr -d ' \t\r\n{}' <"$a")" ]; then rm -f "$a"; fi fi done <"$STATE" rm -f "$STATE" } state_tools() { awk -F'\t' '$1 == "tool" { printf "%s%s", (n++ ? " " : ""), $2 }' "$STATE" 2>/dev/null; } state_field() { awk -F'\t' -v k="$1" -v n="$2" '$1 == k { v = $n } END { print v }' "$STATE" 2>/dev/null; } # --- paths --------------------------------------------------------------------- paths_init() { SCX_DIR="$HOME/.scx" KEY_FILE="$SCX_DIR/key" STATE="$SCX_DIR/state" BACKUP_DIR="$SCX_DIR/backups/$(date +%Y%m%d-%H%M%S)" CLAUDE_DIR=${CLAUDE_CONFIG_DIR:-$HOME/.claude} CLAUDE_SETTINGS="$CLAUDE_DIR/settings.json" if [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then CLAUDE_JSON="$CLAUDE_CONFIG_DIR/.claude.json"; else CLAUDE_JSON="$HOME/.claude.json"; fi CODEX_DIR=${CODEX_HOME:-$HOME/.codex} OC_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode" # Wrappers go in ~/.local/bin when it's already on PATH (usable right away), else ~/.scx/bin. case ":$PATH:" in *":$HOME/.local/bin:"*) WRAP_DIR="$HOME/.local/bin" WRAP_ON_PATH=1 ;; *) WRAP_DIR="$SCX_DIR/bin" WRAP_ON_PATH=0 ;; esac TOUCHED="" } # --- detection ------------------------------------------------------------------- tool_label() { case $1 in claude) echo "Claude Code" ;; codex) echo "Codex" ;; opencode) echo "OpenCode" ;; esac } tool_install() { case $1 in claude) echo "curl -fsSL https://claude.ai/install.sh | bash" ;; codex) echo "npm install -g @openai/codex" ;; opencode) echo "curl -fsSL https://opencode.ai/install | bash" ;; esac } # Directories to search: PATH, then places installers use that a fresh shell may not have on PATH. search_dirs() { local d old_ifs=$IFS IFS=: for d in $PATH; do case $d in /*) printf '%s\n' "$d" ;; esac done IFS=$old_ifs printf '%s\n' "$HOME/.local/bin" "$HOME/.claude/local" "$HOME/.opencode/bin" "$HOME/.bun/bin" \ "$HOME/.npm-global/bin" "$HOME/.volta/bin" "$HOME/.local/share/pnpm" "$HOME/Library/pnpm" \ "$HOME/.asdf/shims" "$HOME/.local/share/mise/shims" "$HOME/.fnm/aliases/default/bin" \ "$HOME/.local/share/fnm/aliases/default/bin" "$HOME/.yarn/bin" "$HOME/bin" for d in "$HOME"/.nvm/versions/node/*/bin; do [ -d "$d" ] && printf '%s\n' "$d" done for d in ${SCX_SYSTEM_DIRS-/opt/homebrew/bin /usr/local/bin /home/linuxbrew/.linuxbrew/bin}; do printf '%s\n' "$d" done } find_bins() { local name=$1 d c seen="|" out="" while IFS= read -r d; do [ -n "$d" ] && [ -f "$d/$name" ] && [ -x "$d/$name" ] || continue c=$(canon "$d/$name") case $seen in *"|$c|"*) continue ;; esac seen="$seen$c|" out="$out$d/$name " done </dev/null /dev/null; } codex_signed_in() { [ -s "$CODEX_DIR/auth.json" ]; } detect() { local t bins first v b i=0 hint missing="" FOUND="" for t in $TOOLS; do bins=$(find_bins "$t") if [ -z "$bins" ]; then missing="$missing $t" continue fi FOUND="$FOUND $t" first=$(printf '%s\n' "$bins" | head -1) v=$(tool_version "$first") eval "BIN_$t=\$first VER_$t=\$v BINS_$t=\$bins" done FOUND=${FOUND# } if [ -z "$FOUND" ]; then say " No supported coding tools found. Install one, then run this again:" for t in $TOOLS; do printf ' %-12s %s\n' "$(tool_label "$t")" "$(tool_install "$t")"; done say "" return 1 fi say " Found:" for t in $FOUND; do i=$((i + 1)) eval "first=\$BIN_$t v=\$VER_$t bins=\$BINS_$t" hint="" [ "$t" = claude ] && claude_signed_in && hint=" ${D}(signed in to Claude)${N}" [ "$t" = codex ] && codex_signed_in && hint=" ${D}(signed in to OpenAI)${N}" printf ' %d. %-12s %-9s %s%s\n' "$i" "$(tool_label "$t")" "${v:-?}" "$(tilde "$first")" "$hint" printf '%s\n' "$bins" | sed 1d | while IFS= read -r b; do [ -n "$b" ] && note " also: $(tilde "$b") ($(tool_version "$b"))" done done for t in $missing; do note "Not installed: $(tool_label "$t") ($(tool_install "$t"))"; done say "" } choose_tools() { local n=0 t a i pick="" for t in $FOUND; do n=$((n + 1)); done if [ -n "$ARG_TOOLS" ]; then for t in $(printf '%s' "$ARG_TOOLS" | tr ',' ' '); do in_list "$t" "$FOUND" || die "$t isn't installed here (found: $FOUND)." pick="$pick $t" done SELECTED=${pick# } return 0 fi if [ "$n" = 1 ]; then ask a " Set up $(tool_label "$FOUND")? [Y/n]: " Y else ask a " Set up all $n? [Y/n, or numbers e.g. \"1 3\"]: " Y fi if yes_no "$a"; then SELECTED=$FOUND elif [ "$a" = n ] || [ "$a" = N ] || [ "$a" = no ]; then say "" say " Nothing changed." exit 0 else for a in $(printf '%s' "$a" | tr ',' ' '); do i=0 for t in $FOUND; do i=$((i + 1)) [ "$a" = "$i" ] && pick="$pick $t" done done [ -n "$pick" ] || die "Pick numbers from the list, e.g. 1 3." SELECTED=${pick# } fi gap } # --- key ----------------------------------------------------------------------- clean_key() { local k k=$(printf '%s' "$1" | tr -d ' \t\r\n') case $k in *=sk-scx-*) k=${k#*=} ;; esac k=${k#\"} k=${k%\"} k=${k#\'} k=${k%\'} printf '%s' "$k" } # api METHOD PATH OUTFILE [BODYFILE]: prints the HTTP status. The key goes in # through curl's stdin config so it never shows up in ps. api() { local m=$1 p=$2 out=$3 body=${4:-} { printf 'header = "Authorization: Bearer %s"\n' "$KEY" if [ "$p" = /v1/messages ]; then printf 'header = "x-api-key: %s"\nheader = "anthropic-version: 2023-06-01"\n' "$KEY" fi [ -n "$body" ] && printf 'data-binary = "@%s"\n' "$body" } | curl -sS -m "${API_TIMEOUT:-90}" -K - -X "$m" -o "$out" -w '%{http_code}' \ -H 'Content-Type: application/json' "$API_BASE$p" 2>/dev/null || true } check_key() { local code KEY=$(clean_key "$1") case $KEY in sk-scx-?*) ;; *) KEY_ERR="SCX keys start with sk-scx-. That doesn't look like one."; return 1 ;; esac code=$(api GET /v1/models "$TMP/models.json") case $code in 200) return 0 ;; 401 | 403) KEY_ERR="SCX rejected this key (HTTP $code). Check it in the dashboard, or create a new one." ;; 000) KEY_ERR="Couldn't reach $API_BASE. Check your connection and try again." ;; *) KEY_ERR="Unexpected answer from $API_BASE (HTTP $code). Try again in a minute." ;; esac return 1 } get_key() { local found="" a if [ -n "${SCX_API_KEY:-}" ]; then found=$SCX_API_KEY; elif [ -s "$KEY_FILE" ]; then found=$(cat "$KEY_FILE"); fi if [ -n "$ARG_KEY" ]; then check_key "$ARG_KEY" || die "$KEY_ERR" elif [ "$YES" = 1 ]; then [ -n "$found" ] || die "No SCX key. With --yes, pass --key sk-scx-... (or set SCX_API_KEY)." check_key "$found" || die "$KEY_ERR" else while :; do if [ -n "$found" ]; then ask_secret a " SCX API key: found $(mask "$found"). Enter to use it, or paste another: " [ -n "$a" ] || a=$found else ask_secret a " SCX API key: paste it, or press Enter to get one: " if [ -z "$a" ]; then [ "${INPUT_EOF:-0}" = 1 ] && die "No key entered." if open_url "$KEYS_URL"; then note "Opened $KEYS_URL. Sign up or sign in, create a key, then paste it here." else say " Get a key at ${C}$KEYS_URL${N} (sign up or sign in, then create a key)." fi ask_secret a " Paste your key: " fi fi if [ -z "$a" ]; then [ "${INPUT_EOF:-0}" = 1 ] && die "No key entered." continue fi check_key "$a" && break bad "$KEY_ERR" [ "${INPUT_EOF:-0}" = 1 ] && die "No valid key entered." [ "$(clean_key "$a")" = "$(clean_key "$found")" ] && found="" done fi load_models ok "Key works ${D}($(count_models) models for coding)${N}" say "" } open_url() { [ -n "${SCX_NO_BROWSER:-}" ] && return 1 if [ "$(uname -s)" = Darwin ]; then open "$1" >/dev/null 2>&1 && return 0 elif grep -qi microsoft /proc/version 2>/dev/null; then if command -v wslview >/dev/null 2>&1; then wslview "$1" >/dev/null 2>&1 && return 0; fi cmd.exe /c start "" "$1" >/dev/null 2>&1 && return 0 elif [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && command -v xdg-open >/dev/null 2>&1; then xdg-open "$1" >/dev/null 2>&1 & return 0 fi return 1 } # --- models ---------------------------------------------------------------------- load_models() { MODELS=$(json models "$TMP/models.json" 2>/dev/null) || MODELS="" if [ -z "$MODELS" ]; then MODELS=$FALLBACK_MODELS warn "Couldn't read your model list, so only the recommended models are offered." fi } count_models() { printf '%s\n' "$MODELS" | grep -c .; } has_model() { printf '%s\n' "$MODELS" | awk -F'\t' -v id="$1" '$1 == id { f = 1 } END { exit !f }'; } model_ids() { printf '%s\n' "$MODELS" | awk -F'\t' '{ printf "%s%s", (NR > 1 ? ", " : ""), $1 }'; } model_field() { printf '%s\n' "$MODELS" | awk -F'\t' -v id="$1" -v n="$2" '$1 == id { print $n; exit }'; } # The window we declare to the tools: 97% of the real one, so they compact before the server refuses. declared_ctx() { local c c=$(model_field "$1" 2) [ -n "$c" ] && [ "$c" -gt 0 ] 2>/dev/null || c=128000 echo $((c * 97 / 100 / 1000 * 1000)) } pick_model() { # pick_model VAR "label" DEFAULT local a id while :; do ask a " $2 [$3]: " "$3" id=$a case $a in *[!0-9]*) ;; *) id=$(printf '%s\n' "$MODELS" | awk -F'\t' -v n="$a" 'NR == n { print $1 }') ;; esac if [ -n "$id" ] && has_model "$id"; then eval "$1=\$id" return 0 fi bad "Pick a number or a model name from the list." [ "${INPUT_EOF:-0}" = 1 ] && die "No model chosen." done } choose_models() { local prev_main prev_fast a i=0 id ctx out rs img extra prev_main=$(state_field models 2) prev_fast=$(state_field models 3) MAIN=${ARG_MAIN:-${prev_main:-$DEFAULT_MAIN}} FAST=${ARG_FAST:-${prev_fast:-$DEFAULT_FAST}} if [ -n "$ARG_MAIN" ] && ! has_model "$MAIN"; then die "$MAIN isn't available for coding on your account. Choose from: $(model_ids)"; fi if [ -n "$ARG_FAST" ] && ! has_model "$FAST"; then die "$FAST isn't available for coding on your account. Choose from: $(model_ids)"; fi has_model "$MAIN" || MAIN=$(printf '%s\n' "$MODELS" | head -1 | cut -f1) has_model "$FAST" || FAST=$MAIN [ -n "$ARG_MAIN$ARG_FAST" ] && return 0 ask a " Models: main = ${B}$MAIN${N}, fast = ${B}$FAST${N}. Keep? [Y/n]: " Y yes_no "$a" && { gap; return 0; } say "" while IFS=$'\t' read -r id ctx out rs img; do i=$((i + 1)) extra="" [ "$img" = 1 ] && extra=" · images" [ "$rs" = 1 ] && extra="$extra · reasoning" if [ "$ctx" -ge 1000000 ] 2>/dev/null; then ctx="$((ctx / 1048576))M"; else ctx="$((ctx / 1000))k"; fi printf ' %2d. %-28s %s%s context%s%s\n' "$i" "$id" "${D}" "$ctx" "$extra" "${N}" done </dev/null) if [ -z \"\$key\" ]; then echo \"claude-scx: no SCX key in $(tilde "$KEY_FILE"). Run: curl -fsSL $SCX_URL | bash\" >&2 exit 1 fi bin=\$(command -v claude 2>/dev/null) || bin=$(shq "$BIN_claude") unset ANTHROPIC_API_KEY$exports export ANTHROPIC_AUTH_TOKEN=\"\$key\" exec \"\$bin\" \"\$@\"" 755 WRAPPERS=1 return 0 fi if [ -f "$CLAUDE_SETTINGS" ] && ! json check "$CLAUDE_SETTINGS"; then bad "$(tilde "$CLAUDE_SETTINGS") isn't plain JSON, so it was left alone. Use --mode option instead." return 1 fi while IFS= read -r pair; do j_set "$CLAUDE_SETTINGS" "env.${pair%%=*}" "$(jstr "${pair#*=}")" || return 1 done </dev/null && manual=1 awk_patch "$cfg" "settings that clash with SCX" -v mode="$MODE" "$(codex_awk)" || return 1 write_owned "$CODEX_DIR/scx.config.toml" "$(codex_profile scx "$MAIN")" 600 write_owned "$CODEX_DIR/scx-fast.config.toml" "$(codex_profile scx-fast "$FAST")" 600 if [ "$MODE" = default ]; then block_prepend "$cfg" "$(printf '%s\n' 'model_provider = "scx"' "model = $(jstr "$MAIN")" "model_context_window = $(declared_ctx "$MAIN")")" fi if [ "$MODE" = default ] || [ "$manual" = 1 ]; then block_append "$cfg" "$(codex_provider_table)" fi if [ "$MODE" = option ]; then write_owned "$WRAP_DIR/codex-scx" "#!/bin/sh # codex-scx: Codex on SCX.ai. Added by SCX.ai setup ($SCX_URL). bin=\$(command -v codex 2>/dev/null) || bin=$(shq "$BIN_codex") exec \"\$bin\" --profile scx \"\$@\"" 755 WRAPPERS=1 fi if [ -n "${VER_codex:-}" ] && ! ver_ge "$VER_codex" "$CODEX_AUTH_MIN"; then warn "Codex $VER_codex reads the key from \$SCX_API_KEY; open a new terminal before using it (or update Codex)." fi } codex_summary() { if [ "$MODE" = option ]; then printf '%-12s %s' "Codex" "${B}codex-scx${N} ${D}· $MAIN (fast: codex --profile scx-fast)${N}" else printf '%-12s %s' "Codex" "${B}codex${N} ${D}· $MAIN (fast: codex --profile scx-fast)${N}" fi } # --- OpenCode ---------------------------------------------------------------------- opencode_provider() { local id ctx out rs img models="" lim r while IFS=$'\t' read -r id ctx out rs img; do [ -n "$id" ] || continue r=false [ "$rs" = 1 ] && r=true lim="" [ "${ctx:-0}" -gt 0 ] 2>/dev/null && [ "${out:-0}" -gt 0 ] 2>/dev/null && lim=",\"limit\":{\"context\":$ctx,\"output\":$out}" models="$models,$(jstr "$id"):{\"name\":$(jstr "$id"),\"reasoning\":$r,\"tool_call\":true$lim}" done </dev/null; then sed 's/^/ /' "$tmp"; else say " {\"provider\": {\"scx\": $(opencode_provider)}}"; fi return 1 fi [ -f "$f" ] || new=1 [ "$new" = 1 ] && { j_set "$f" '$schema' '"https://opencode.ai/config.json"' || return 1; } j_set "$f" provider.scx "$(opencode_provider)" || return 1 if [ "$MODE" = default ]; then j_set "$f" model "$(jstr "scx/$MAIN")" || return 1 j_set "$f" small_model "$(jstr "scx/$FAST")" || return 1 j_set "$f" agent.plan.model "$(jstr "scx/$MAIN")" || return 1 j_set "$f" agent.build.model "$(jstr "scx/$FAST")" || return 1 else write_owned "$WRAP_DIR/opencode-scx" "#!/bin/sh # opencode-scx: OpenCode on SCX.ai. Added by SCX.ai setup ($SCX_URL). bin=\$(command -v opencode 2>/dev/null) || bin=$(shq "$BIN_opencode") OPENCODE_CONFIG_CONTENT=$(shq "$content") exec \"\$bin\" \"\$@\"" 755 WRAPPERS=1 fi } opencode_summary() { if [ "$MODE" = option ]; then printf '%-12s %s' "OpenCode" "${B}opencode-scx${N} ${D}· or pick SCX.ai in /models${N}" else printf '%-12s %s' "OpenCode" "${B}opencode${N} ${D}· plan: $MAIN, build: $FAST${N}" fi } # --- shell profile ------------------------------------------------------------------ rc_files() { case $(basename "${SHELL:-sh}") in zsh) echo "${ZDOTDIR:-$HOME}/.zshrc" ;; bash) echo "$HOME/.bashrc" [ "$(uname -s)" = Darwin ] && echo "$HOME/.bash_profile" ;; fish) echo fish ;; *) echo "$HOME/.profile" ;; esac } # The old docs had users paste a claude-scx() function; it would shadow the wrapper. rc_fn_awk() { cat <<'EOF' infn { print "# scx: " $0; if ($0 ~ /^[ \t]*}[ \t;]*$/) infn = 0; next } $0 ~ /^[ \t]*(function[ \t]+claude-scx|claude-scx[ \t]*\(\))/ { print "# scx: " $0; if ($0 !~ /}[ \t;]*$/) infn = 1; next } { print } EOF } rc_apply() { local lines="" fish_lines="" f for f in "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" "${ZDOTDIR:-$HOME}/.zshrc"; do grep -qE '^[[:space:]]*(function[[:space:]]+claude-scx|claude-scx[[:space:]]*\(\))' "$f" 2>/dev/null && awk_patch "$f" "an old claude-scx function" "$(rc_fn_awk)" done if [ "$NO_ENV" = 0 ]; then lines="export SCX_API_KEY=\"\$(cat $(shq "$KEY_FILE") 2>/dev/null)\"" fish_lines="set -gx SCX_API_KEY (cat $(shq "$KEY_FILE") 2>/dev/null)" fi if [ "${WRAPPERS:-0}" = 1 ] && [ "$WRAP_ON_PATH" = 0 ]; then lines="${lines:+$lines }export PATH=\"$WRAP_DIR:\$PATH\"" fish_lines="${fish_lines:+$fish_lines }set -gx PATH $(shq "$WRAP_DIR") \$PATH" NEW_TERMINAL=1 fi [ -n "$lines" ] || return 0 RC_DONE="" for f in $(rc_files); do if [ "$f" = fish ]; then f="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/scx.fish" write_owned "$f" "# Added by SCX.ai setup ($SCX_URL) $fish_lines" 644 else block_append "$f" "$lines" fi RC_DONE="$RC_DONE $(tilde "$f")" done } # --- apply / verify / remove ---------------------------------------------------------- apply_all() { local t say " Setting up${ELL}" if [ "$DRY" = 1 ]; then [ -f "$STATE" ] && say " would first undo the current SCX setup" else revert_all mkdir -p "$SCX_DIR" chmod 700 "$SCX_DIR" (umask 077 && printf '%s\n' "$KEY" >"$KEY_FILE") chmod 600 "$KEY_FILE" state_add version "$SCX_CLI_VERSION" state_add models "$MAIN" "$FAST" fi DONE_TOOLS="" FAILED_TOOLS="" WRAPPERS=0 NEW_TERMINAL=0 for t in $SELECTED; do if "${t}_apply"; then DONE_TOOLS="$DONE_TOOLS $t" [ "$DRY" = 1 ] || state_add tool "$t" "$MODE" else FAILED_TOOLS="$FAILED_TOOLS $t" fi done DONE_TOOLS=${DONE_TOOLS# } rc_apply [ "$DRY" = 1 ] || finalize_state } verify_body() { case $1 in claude) printf '{"model":%s,"max_tokens":64,"messages":[{"role":"user","content":"Reply with ok"}]}' "$(jstr "$MAIN")" ;; codex) printf '{"model":%s,"input":"Reply with ok","max_output_tokens":64}' "$(jstr "$MAIN")" ;; opencode) printf '{"model":%s,"max_tokens":64,"messages":[{"role":"user","content":"Reply with ok"}]}' "$(jstr "$MAIN")" ;; esac } verify_path() { case $1 in claude) echo /v1/messages ;; codex) echo /v1/responses ;; opencode) echo /v1/chat/completions ;; esac } # One request per tool, on the protocol that tool speaks, in parallel. verify_all() { local t for t in $DONE_TOOLS; do verify_body "$t" >"$TMP/body.$t" (api POST "$(verify_path "$t")" "$TMP/resp.$t" "$TMP/body.$t" >"$TMP/code.$t") & done wait } deep_cmd() { local t=$1 p="Reply with the single word: ok" case $t.$MODE in claude.option) "$WRAP_DIR/claude-scx" -p "$p" ;; claude.default) claude -p "$p" ;; codex.option) "$WRAP_DIR/codex-scx" exec --skip-git-repo-check "$p" ;; codex.default) codex exec --skip-git-repo-check "$p" ;; opencode.option) "$WRAP_DIR/opencode-scx" run "$p" ;; opencode.default) opencode run "$p" ;; esac } summary() { local t code line msg rc=0 for t in $DONE_TOOLS; do line=$("${t}_summary") if [ "$VERIFY" = 1 ] && [ "$DRY" = 0 ]; then code=$(cat "$TMP/code.$t" 2>/dev/null) if [ "$code" = 200 ]; then line="$line ${D}·${N} ${G}verified${N}" else msg=$(grep -o '"message"[^,}]*' "$TMP/resp.$t" 2>/dev/null | head -1 | sed 's/"message"[[:space:]]*:[[:space:]]*//') bad "$line · test request failed (HTTP ${code:-none}${msg:+: $msg})" rc=2 continue fi fi if [ "$DEEP" = 1 ] && [ "$DRY" = 0 ]; then with_timeout 240 deep_cmd "$t" "$TMP/deep.$t" 2>&1 # The answer is a line that is just "ok" (tools print logs and token counts around it). if sed "s/$(printf '\033')\[[0-9;]*m//g" "$TMP/deep.$t" | grep -qiE '^[[:space:][:punct:]]*ok[[:space:][:punct:]]*$'; then line="$line ${D}·${N} ${G}ran end to end${N}" else bad "$line · $(tool_label "$t") itself didn't answer. Run it by hand to see the error." rc=2 continue fi fi ok "$line" done for t in $FAILED_TOOLS; do bad "$(tool_label "$t") wasn't set up (see above)" rc=2 done [ -n "$DONE_TOOLS" ] || return 1 if [ "$DRY" = 1 ]; then say "" say " Dry run: nothing was changed." return 0 fi [ -n "${RC_DONE:-}" ] && [ "$NO_ENV" = 0 ] && ok "SCX_API_KEY exported in$RC_DONE" note " Backups: $(tilde "$BACKUP_DIR")" if [ "$MODE" = default ] && in_list claude "$DONE_TOOLS"; then claude_signed_in && warn "claude and its IDE extension now use SCX; your Claude login is kept for when you remove it." [ -n "${ANTHROPIC_API_KEY:-}${ANTHROPIC_AUTH_TOKEN:-}" ] && warn "ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN is set in your shell and overrides SCX. Unset it, or use --mode option." fi say "" t=${DONE_TOOLS%% *} if [ "$MODE" = option ]; then if [ "$NEW_TERMINAL" = 1 ]; then say " Open a new terminal (or run: source $(rc_files | head -1 | sed "s#^$HOME#~#")), then try: ${B}$t-scx${N}" else say " Try: ${B}$t-scx${N}" fi else say " Try: ${B}$t${N}" fi say " Undo: curl -fsSL $SCX_URL | bash -s -- --remove" say "" return $rc } do_remove() { local tools a t names="" if [ ! -f "$STATE" ]; then say " Nothing to remove: this script hasn't changed anything here." else tools=$(state_tools) revert_all for t in $tools; do names="$names, $(tool_label "$t")"; done ok "Removed SCX from ${names#, }" fi if [ -f "$KEY_FILE" ]; then if [ "$YES" = 1 ]; then note "Kept your key in $(tilde "$KEY_FILE") for next time." else ask a " Also delete your saved SCX key ($(tilde "$KEY_FILE"))? [y/N]: " N case $a in y | Y | yes) rm -rf "$SCX_DIR" ok "Deleted $(tilde "$SCX_DIR")" ;; *) note "Kept your key in $(tilde "$KEY_FILE") for next time." ;; esac fi fi say "" } parse_args() { YES=0 REMOVE=0 DRY=0 DEEP=0 NO_ENV=0 VERIFY=1 ARG_KEY="" ARG_TOOLS="" ARG_MODE="" ARG_MAIN="" ARG_FAST="" while [ $# -gt 0 ]; do case $1 in -y | --yes) YES=1 ;; --remove | --uninstall | --revert) REMOVE=1 ;; --dry-run) DRY=1 ;; --deep) DEEP=1 ;; --no-env) NO_ENV=1 ;; --no-verify) VERIFY=0 ;; --key | --tools | --mode | --main | --fast) [ $# -ge 2 ] || die "$1 needs a value (see --help)." case $1 in --key) ARG_KEY=$2 ;; --tools) ARG_TOOLS=$2 ;; --mode) ARG_MODE=$2 ;; --main) ARG_MAIN=$2 ;; --fast) ARG_FAST=$2 ;; esac shift ;; --key=* | --tools=* | --mode=* | --main=* | --fast=*) a=${1#*=} case $1 in --key=*) ARG_KEY=$a ;; --tools=*) ARG_TOOLS=$a ;; --mode=*) ARG_MODE=$a ;; --main=*) ARG_MAIN=$a ;; --fast=*) ARG_FAST=$a ;; esac ;; -h | --help) usage; exit 0 ;; -V | --version) echo "$SCX_CLI_VERSION"; exit 0 ;; *) die "Unknown option: $1 (see --help)" ;; esac shift done } main() { if [ -z "${BASH_VERSION:-}" ]; then echo "Please run this with bash: curl -fsSL $SCX_URL | bash" >&2 exit 1 fi set -u -o pipefail parse_args "$@" ui_init command -v curl >/dev/null 2>&1 || die "This needs curl." paths_init TMP=$(mktemp -d 2>/dev/null || mktemp -d -t scx) trap 'rm -rf "$TMP"' EXIT tty_init say "" say " ${B}SCX.ai${N} — connect your coding tools ${D}(v$SCX_CLI_VERSION)${N}" say "" if [ "$REMOVE" = 1 ]; then do_remove exit 0 fi if [ -f "$STATE" ] && [ "$YES" = 0 ] && [ "$DRY" = 0 ]; then local a say " SCX is already set up here ($(state_tools | sed 's/ /, /g'), $(state_field tool 3) mode, $(state_field models 2) / $(state_field models 3))." say " 1) Update: re-detect tools, change models, mode or key" say " 2) Remove SCX from all tools" ask a " Choose [1]: " 1 say "" if [ "$a" = 2 ]; then do_remove exit 0 fi fi detect || exit 1 choose_tools get_key choose_models choose_mode apply_all [ "$VERIFY" = 1 ] && [ "$DRY" = 0 ] && verify_all say "" summary } main "$@"