#!/bin/sh # Goedel Machines installer: connects open-source coding-agent harnesses to the # Goedel Machines OpenAI-compatible API (model deepseek/deepseek-v4.1-flash). # # curl -fsSL https://inference.goedelmachines.com/install.sh | sh # curl -fsSL https://inference.goedelmachines.com/install.sh | sh -s -- --yes # # POSIX sh (dash, bash, macOS sh). Needs curl; uses jq when present. # # Environment: # GOEDEL_API_KEY use this key instead of the browser (device) flow # GOEDEL_SITE key-issuing site (default https://inference.goedelmachines.com) # GOEDEL_BASE_URL API base URL (default: from the site, else https://api.goedelmachines.com/v1) # GOEDEL_SKIP_LIVE=1 skip the final live test call # # Key type: free keys (sk-gm-free-..., and older sk-gm-... keys) have their prompts stored under the free-tier terms; # paid keys (sk-gm-paid-...) have zero data retention. The installer says which one is in use. The label goes into the # model's display name where the harness has one, and into the provider's name for Goose and Codex (see set_tier). # Aider and the manual Kilo Code, Cline and Roo Code steps show it only in the installer's output. --new-key signs in # again, e.g. to choose Paid. # # The key is stored once, mode 0600, in ${XDG_CONFIG_HOME:-~/.config}/goedel/api_key. # Every harness that can run a command or read a file reads it from there; the # key is copied into another file only where the harness offers no other way # (Aider, Continue), and that file is made 0600. set -u VERSION=0.1.0 PROVIDER_ID=goedel PROVIDER_NAME="Goedel Machines" DEFAULT_SITE=https://inference.goedelmachines.com DEFAULT_BASE_URL=https://api.goedelmachines.com/v1 MODEL=deepseek/deepseek-v4.1-flash MODEL_NAME="DeepSeek V4.1 Flash" # Context window the server accepts (GET /v1/models: max_context_length). CONTEXT=1048576 # Output budget each harness reserves per request. The server allows up to # 943718 output tokens, but harnesses subtract this number from the usable # prompt window, so it is a chosen per-request cap, not a server limit. MAX_OUTPUT=65536 SITE=${GOEDEL_SITE:-$DEFAULT_SITE} SITE=${SITE%/} CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} GOEDEL_DIR=$CONFIG_HOME/goedel KEY_FILE=$GOEDEL_DIR/api_key TS=$(date +%Y%m%d-%H%M%S) BEGIN_MARK="# >>> goedel (managed by goedel install.sh) >>>" END_MARK="# <<< goedel <<<" DRY_RUN=0 NEW_KEY=0 YES=0 LIST=0 UNINSTALL=0 ONLY="" ALL_HARNESSES="opencode codex aider crush goose continue omp pi prime kilo cline roo claude" # ---------------------------------------------------------------- output if [ -t 1 ]; then B=$(printf '\033[1m'); D=$(printf '\033[2m'); G=$(printf '\033[32m'); Y=$(printf '\033[33m'); R=$(printf '\033[31m'); N=$(printf '\033[0m'); else B=; D=; G=; Y=; R=; N=; fi say() { printf '%s\n' "$*"; } info() { printf '%s==>%s %s\n' "$B" "$N" "$*"; } ok() { printf '%s ok%s %s\n' "$G" "$N" "$*"; } warn() { printf '%swarn%s %s\n' "$Y" "$N" "$*" >&2; } die() { printf '%serror%s %s\n' "$R" "$N" "$*" >&2; exit 1; } usage() { cat <[,...]] [--list] [--uninstall] --yes, -y non-interactive: configure every detected harness --dry-run show what would change; change nothing, request no key --new-key sign in again for a new key (e.g. to choose Paid: zero data retention), ignoring the saved key and GOEDEL_API_KEY; the old key and env are backed up --only H only this harness (repeatable or comma-separated) --list list supported harnesses and whether each is detected --uninstall remove our provider entries (a backup is taken first) --help this text Harnesses: $ALL_HARNESSES EOF } # ---------------------------------------------------------------- args while [ $# -gt 0 ]; do case $1 in --dry-run) DRY_RUN=1 ;; --new-key) NEW_KEY=1 ;; --yes|-y) YES=1 ;; --list) LIST=1 ;; --uninstall) UNINSTALL=1 ;; --only) [ $# -ge 2 ] || die "--only needs a harness name"; ONLY="$ONLY $(printf '%s' "$2" | tr ',' ' ')"; shift ;; --only=*) ONLY="$ONLY $(printf '%s' "${1#--only=}" | tr ',' ' ')" ;; -h|--help) usage; exit 0 ;; *) usage >&2; die "unknown option: $1" ;; esac shift done for h in $ONLY; do case " $ALL_HARNESSES " in *" $h "*) ;; *) die "unknown harness '$h' (one of: $ALL_HARNESSES)" ;; esac done command -v curl >/dev/null 2>&1 || die "curl is required" HAVE_JQ=0; command -v jq >/dev/null 2>&1 && HAVE_JQ=1 HAVE_PY=0; command -v python3 >/dev/null 2>&1 && HAVE_PY=1 TMP=$(umask 077; mktemp -d 2>/dev/null || mktemp -d -t goedel) || die "mktemp failed" trap 'rm -rf "$TMP"' EXIT INT TERM # Interactive input comes from the terminal: under `curl | sh` stdin is the script. TTY="" if [ -r /dev/tty ] && [ -w /dev/tty ] && (exec /dev/null; then TTY=/dev/tty; fi ask() { # ask -> REPLY REPLY="" [ -n "$TTY" ] || return 1 printf '%s' "$1" >"$TTY" IFS= read -r REPLY <"$TTY" || return 1 } # ---------------------------------------------------------------- JSON (jq, else sed) json_str() { # json_str (stdin: a flat JSON object) if [ $HAVE_JQ = 1 ]; then jq -r --arg k "$1" '.[$k] // empty | tostring' 2>/dev/null else tr -d '\n' | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | sed 's#\\/#/#g' fi } json_num() { if [ $HAVE_JQ = 1 ]; then jq -r --arg k "$1" '.[$k] // empty | tostring' 2>/dev/null else tr -d '\n' | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p" fi } json_esc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } # ---------------------------------------------------------------- key helpers mask() { # never print a key in full: show its last 4 characters case $1 in "") printf '(none)' ;; *) printf '...%s' "$(printf '%s' "$1" | tail -c 4)" ;; esac } mask_stream() { # replace the key in a stream with its masked form if [ -n "${API_KEY:-}" ]; then awk -v k="$API_KEY" -v m="$(mask "$API_KEY")" '{ while ((i = index($0, k)) > 0) $0 = substr($0, 1, i-1) m substr($0, i+length(k)); print }' else cat; fi } # http [json-body] -> HTTP_CODE, HTTP_BODY, HTTP_RETRY_AFTER http() { : >"$TMP/hdr"; : >"$TMP/body" if [ $# -ge 3 ]; then printf '%s' "$3" >"$TMP/req" HTTP_CODE=$(curl -sS -X "$1" -H 'content-type: application/json' -H 'accept: application/json' \ --max-time 30 -D "$TMP/hdr" -o "$TMP/body" -w '%{http_code}' --data-binary @"$TMP/req" "$2" 2>"$TMP/err") || HTTP_CODE=000 else HTTP_CODE=$(curl -sS -X "$1" --max-time 30 -D "$TMP/hdr" -o "$TMP/body" -w '%{http_code}' "$2" 2>"$TMP/err") || HTTP_CODE=000 fi HTTP_BODY=$(cat "$TMP/body") HTTP_RETRY_AFTER=$(sed -n 's/^[Rr][Ee][Tt][Rr][Yy]-[Aa][Ff][Tt][Ee][Rr]:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$TMP/hdr" | tail -n 1) } # serves : does the API route this path? Asked WITH the key and our model # id but no input, a route that exists answers 400/422 (nothing is generated); # one that does not answers 404. The model id matters: without it the gateway # answers 404 "model not found" on every route. Without a valid key every path # answers 401 (the gateway authenticates first), which counts as "served". serves() { printf '{"model":"%s"}' "$MODEL" >"$TMP/req" HTTP_CODE=$(curl -sS -X POST -H 'content-type: application/json' ${API_KEY:+-H "Authorization: Bearer $API_KEY"} \ --max-time 30 -o /dev/null -w '%{http_code}' --data-binary @"$TMP/req" "$1" 2>/dev/null) || HTTP_CODE=000 [ "$HTTP_CODE" != 404 ] } open_url() { if command -v open >/dev/null 2>&1 && [ "$(uname -s)" = Darwin ]; then open "$1" >/dev/null 2>&1 & elif command -v xdg-open >/dev/null 2>&1 && { [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; }; then xdg-open "$1" >/dev/null 2>&1 & fi } # Device flow (RFC 8628 shape): POST /api/device/code, user approves in a # browser, poll POST /api/device/token at `interval`, honour slow_down and 429. # A 200 carries {api_key, base_url, model, key_name, tier}. `no_credit` means a paid # key was approved but the balance is 0: the code stays approved, so keep polling. device_flow() { info "Requesting a key from $SITE" http POST "$SITE/api/device/code" '{"client_name":"install.sh"}' [ "$HTTP_CODE" = 200 ] || die "could not start sign-in ($SITE/api/device/code -> HTTP $HTTP_CODE): $(printf '%s' "$HTTP_BODY" | head -c 300)$(cat "$TMP/err")" dc=$(printf '%s' "$HTTP_BODY" | json_str device_code) uc=$(printf '%s' "$HTTP_BODY" | json_str user_code) vu=$(printf '%s' "$HTTP_BODY" | json_str verification_uri) vuc=$(printf '%s' "$HTTP_BODY" | json_str verification_uri_complete) ex=$(printf '%s' "$HTTP_BODY" | json_num expires_in) iv=$(printf '%s' "$HTTP_BODY" | json_num interval) [ -n "$dc" ] && [ -n "$uc" ] || die "unexpected response from $SITE/api/device/code" [ -n "$vuc" ] || vuc=$vu ex=${ex:-600}; iv=${iv:-5} say "" say " Open this page and approve the request:" say "" say " ${B}$vuc${N}" say "" say " Code: ${B}$uc${N} (check it matches the page; expires in $((ex / 60)) min)" say "" open_url "$vuc" deadline=$(( $(date +%s) + ex )) body="{\"device_code\":\"$(json_esc "$dc")\"}" credit_msg="" while :; do [ "$(date +%s)" -lt "$deadline" ] || die "${credit_msg:-the code expired before it was approved; run the installer again}" sleep "$iv" http POST "$SITE/api/device/token" "$body" if [ "$HTTP_CODE" = 200 ]; then API_KEY=$(printf '%s' "$HTTP_BODY" | json_str api_key) tok_base=$(printf '%s' "$HTTP_BODY" | json_str base_url) tok_model=$(printf '%s' "$HTTP_BODY" | json_str model) TOKEN_TIER=$(printf '%s' "$HTTP_BODY" | json_str tier) [ -n "$API_KEY" ] || die "the site approved the request but returned no key" [ -n "$tok_base" ] && [ -z "${GOEDEL_BASE_URL:-}" ] && BASE_URL=${tok_base%/} [ -n "$tok_model" ] && MODEL=$tok_model ok "approved; key $(mask "$API_KEY")" return 0 fi if [ "$HTTP_CODE" = 429 ]; then sleep "${HTTP_RETRY_AFTER:-$iv}"; continue fi err=$(printf '%s' "$HTTP_BODY" | json_str error) desc=$(printf '%s' "$HTTP_BODY" | json_str error_description) case $err in authorization_pending) printf '.' ;; no_credit) if [ -z "$credit_msg" ]; then say ""; say " Your credit balance is 0; add credit at $SITE/billing and the key will be issued" credit_msg="the code expired while your credit balance was 0; add credit at $SITE/billing, then run the installer again" fi printf '.' ;; slow_down) iv=$((iv + 5)) ;; expired_token) say ""; die "${credit_msg:-the code expired; run the installer again}" ;; access_denied) say ""; die "the request was declined in the browser" ;; key_limit) say ""; die "${desc:-you already have the maximum number of keys; delete one at $SITE/keys}" ;; invalid_grant) say ""; die "${desc:-the code is unknown or its key was already collected; run the installer again}" ;; *) if [ "$HTTP_CODE" = 000 ]; then printf '!'; else say ""; die "sign-in failed (HTTP $HTTP_CODE): ${err:-$(printf '%s' "$HTTP_BODY" | head -c 300)} $desc"; fi ;; esac done } save_key() { if [ $DRY_RUN = 1 ]; then say "${D}would write $KEY_FILE (0600)${N}"; return; fi (umask 077; mkdir -p "$GOEDEL_DIR") || die "cannot create $GOEDEL_DIR" chmod 700 "$GOEDEL_DIR" 2>/dev/null if [ -f "$KEY_FILE" ] && [ "$(cat "$KEY_FILE")" != "$API_KEY" ]; then cp -p "$KEY_FILE" "$KEY_FILE.bak-goedel-$TS" [ -f "$GOEDEL_DIR/env" ] && cp -p "$GOEDEL_DIR/env" "$GOEDEL_DIR/env.bak-goedel-$TS" fi (umask 077; printf '%s\n' "$API_KEY" >"$KEY_FILE") || die "cannot write $KEY_FILE" chmod 600 "$KEY_FILE" (umask 077; printf 'GOEDEL_API_KEY=%s\nGOEDEL_BASE_URL=%s\nGOEDEL_MODEL=%s\nGOEDEL_SITE=%s\nGOEDEL_KEY_TIER=%s\n' \ "$API_KEY" "$BASE_URL" "$MODEL" "$SITE" "$KEY_TIER" >"$GOEDEL_DIR/env") chmod 600 "$GOEDEL_DIR/env" ok "key $(mask "$API_KEY") saved to $KEY_FILE (0600)" } # A saved key is reused only on the site that issued it (GOEDEL_SITE in the env file). A key from another site, such as a # dev or staging install, means a new sign-in. Installs from before the site was recorded count as the default site # when their saved API base is the default one. saved_env() { [ -f "$GOEDEL_DIR/env" ] && sed -n "s/^$1=//p" "$GOEDEL_DIR/env" | head -n 1; return 0; } key_is_for_site() { ks=$(saved_env GOEDEL_SITE) if [ -n "$ks" ]; then [ "${ks%/}" = "$SITE" ]; return; fi kb=$(saved_env GOEDEL_BASE_URL) [ "$SITE" = "$DEFAULT_SITE" ] && { [ -z "$kb" ] || [ "${kb%/}" = "$DEFAULT_BASE_URL" ]; } } # Key type. The site's word decides: the token response's `tier`, or the GOEDEL_KEY_TIER an earlier run recorded # for this same key. Without it the prefix does: sk-gm-paid- is paid, anything else (sk-gm-free-, older sk-gm-) free. key_tier() { # key_tier [tier from the site] -> paid|free case ${2:-} in paid|free) printf '%s' "$2"; return ;; esac case $1 in sk-gm-paid-*) printf paid ;; *) printf free ;; esac } recorded_tier() { [ "$(saved_env GOEDEL_API_KEY)" = "$API_KEY" ] && saved_env GOEDEL_KEY_TIER; return 0; } # set_tier [tier from the site]: KEY_TIER, the terminal line (KEY_NOTE) and the labels the harnesses show. set_tier() { KEY_TIER=$(key_tier "$API_KEY" "${1:-}") if [ "$KEY_TIER" = paid ]; then TIER_TAG="Paid: zero data retention" KEY_NOTE="Paid key: zero data retention. Prompts and outputs are never stored or used for training" else TIER_TAG="Free: prompts stored" KEY_NOTE="Free key: prompts are stored (free-tier terms, $SITE/legal#free-tier)" fi MODEL_LABEL="$MODEL_NAME ($TIER_TAG)" # where a harness shows the model's name MODEL_LABEL_GM="$MODEL_NAME ($PROVIDER_NAME, $TIER_TAG)" # where it showed "$MODEL_NAME ($PROVIDER_NAME)" PROVIDER_LABEL="$PROVIDER_NAME ($TIER_TAG)" # where only a provider name is shown (Codex, Goose) } KEY_TIER=free; TIER_TAG=""; KEY_NOTE=""; MODEL_LABEL=$MODEL_NAME; MODEL_LABEL_GM=$MODEL_NAME; PROVIDER_LABEL=$PROVIDER_NAME new_key_cmd() { # the command that signs in again on this site if [ "$SITE" = "$DEFAULT_SITE" ]; then printf 'curl -fsSL %s/install.sh | sh -s -- --new-key' "$SITE" else printf 'curl -fsSL %s/install.sh | GOEDEL_SITE=%s sh -s -- --new-key' "$SITE" "$SITE"; fi } get_key() { BASE_URL=${GOEDEL_BASE_URL:-$DEFAULT_BASE_URL} BASE_URL=${BASE_URL%/} TOKEN_TIER="" if [ $NEW_KEY = 0 ] && [ -n "${GOEDEL_API_KEY:-}" ]; then API_KEY=$GOEDEL_API_KEY; ok "using GOEDEL_API_KEY $(mask "$API_KEY")" elif [ $NEW_KEY = 0 ] && [ -s "$KEY_FILE" ] && key_is_for_site; then API_KEY=$(head -n 1 "$KEY_FILE"); ok "using saved key $(mask "$API_KEY") from $KEY_FILE (--new-key gets a new one)" if [ -z "${GOEDEL_BASE_URL:-}" ]; then saved=$(saved_env GOEDEL_BASE_URL); [ -n "$saved" ] && BASE_URL=$saved fi else if [ $NEW_KEY = 1 ]; then [ -n "${GOEDEL_API_KEY:-}" ] && warn "--new-key: ignoring GOEDEL_API_KEY" [ -s "$KEY_FILE" ] && say "--new-key: signing in again instead of using the saved key $(mask "$(head -n 1 "$KEY_FILE")") (it is backed up)" elif [ -s "$KEY_FILE" ]; then from=$(saved_env GOEDEL_SITE); [ -n "$from" ] || from=$(saved_env GOEDEL_BASE_URL); [ -n "$from" ] || from="another site" warn "the saved key $(mask "$(head -n 1 "$KEY_FILE")") is for $from, not $SITE: signing in again (the old key is backed up)" fi if [ $DRY_RUN = 1 ]; then API_KEY=sk-gm-free-DRYRUNDRYRUNDRYRUNDRYRUNDRYRUNDRYRUNDRYR say "${D}dry run: no key requested; using a placeholder${N}" else device_flow fi fi case $API_KEY in sk-*) ;; *) warn "the key does not look like an sk-... key; continuing" ;; esac set_tier "${TOKEN_TIER:-$(recorded_tier)}" say " $KEY_NOTE" save_key } # ---------------------------------------------------------------- file helpers # strip_block : print the file without our marked block strip_block() { [ -f "$1" ] || return 0 awk -v b="$BEGIN_MARK" -v e="$END_MARK" ' { t = $0; sub(/^[ \t]+/, "", t) } t == b { skip = 1; next } skip && t == e { skip = 0; next } !skip { print }' "$1" } has_block() { [ -f "$1" ] && grep -qF "$BEGIN_MARK" "$1"; } backup() { [ -e "$1" ] || return 0 cp -p "$1" "$1.bak-goedel-$TS" && say " ${D}backup: $1.bak-goedel-$TS${N}" } # validate : 0 if it parses (or no checker is available) validate() { case $1 in json) [ $HAVE_JQ = 1 ] || return 0; jq -e . "$2" >/dev/null 2>&1 ;; toml) [ $HAVE_PY = 1 ] || return 0 python3 -c 'import sys try: import tomllib except ImportError: sys.exit(0) tomllib.load(open(sys.argv[1], "rb"))' "$2" 2>/dev/null ;; yaml) [ $HAVE_PY = 1 ] || return 0 python3 -c 'import sys try: import yaml except ImportError: sys.exit(0) yaml.safe_load(open(sys.argv[1]))' "$2" 2>/dev/null ;; *) return 0 ;; esac } # apply [mode]: write candidate over target # (backup first), or in --dry-run show the diff. Returns 1 if nothing changed. apply() { tgt=$1; cand=$2; kind=$3; mode=${4:-} if [ -f "$tgt" ] && cmp -s "$tgt" "$cand"; then say " ${D}unchanged: $tgt${N}" [ -n "$mode" ] && [ $DRY_RUN = 0 ] && chmod "$mode" "$tgt" return 1 fi if ! validate "$kind" "$cand"; then warn "generated $kind for $tgt did not parse; not writing it" return 2 fi if [ $DRY_RUN = 1 ]; then if [ -f "$tgt" ]; then say " would modify $tgt:"; else say " would create $tgt:"; fi if [ -f "$tgt" ] && command -v diff >/dev/null 2>&1; then diff -u "$tgt" "$cand" | tail -n +3 | mask_stream | sed 's/^/ /' else mask_stream <"$cand" | sed 's/^/ + /' fi [ -n "$mode" ] && say " (mode $mode)" return 0 fi mkdir -p "$(dirname "$tgt")" || return 2 if [ -f "$tgt" ]; then backup "$tgt" cat "$cand" >"$tgt" || return 2 # keeps inode, owner, symlinks else if [ -n "$mode" ]; then (umask 077; cat "$cand" >"$tgt") || return 2; else cat "$cand" >"$tgt" || return 2; fi fi [ -n "$mode" ] && chmod "$mode" "$tgt" say " wrote $tgt" return 0 } # remove_file : remove a file that is entirely ours remove_file() { [ -f "$1" ] || return 1 if [ $DRY_RUN = 1 ]; then say " would remove $1"; return 0; fi backup "$1"; rm -f "$1"; say " removed $1" } # snippet : print instead of writing snippet() { warn "$3" say " Add this to ${B}$1${N} yourself:" say "" mask_stream <"$2" | sed 's/^/ /' say "" SNIPPET_PRINTED=1 } # json_merge [mode] # New file: write the fresh document. Existing file: merge with jq; without jq # (or if the file does not parse, e.g. JSONC comments) print the snippet. json_merge() { tgt=$1; filter=$2; fresh=$3; mode=${4:-} if [ ! -s "$tgt" ]; then cp "$fresh" "$TMP/cand"; apply "$tgt" "$TMP/cand" json "$mode"; return $? fi if [ $HAVE_JQ = 0 ]; then snippet "$tgt" "$fresh" "jq is not installed, so $tgt was not merged automatically"; return 2; fi if ! jq -e . "$tgt" >/dev/null 2>&1; then snippet "$tgt" "$fresh" "$tgt is not plain JSON (comments?); not editing it"; return 2; fi jq --slurpfile new "$fresh" "$filter" "$tgt" >"$TMP/cand" || { snippet "$tgt" "$fresh" "could not merge $tgt"; return 2; } apply "$tgt" "$TMP/cand" json "$mode" } # json_unmerge json_unmerge() { [ -s "$1" ] || return 1 [ $HAVE_JQ = 1 ] || { warn "jq is needed to edit $1; remove the \"$PROVIDER_ID\" entry by hand"; return 2; } jq -e . "$1" >/dev/null 2>&1 || { warn "$1 is not plain JSON; remove the \"$PROVIDER_ID\" entry by hand"; return 2; } jq "$2" "$1" >"$TMP/cand" && apply "$1" "$TMP/cand" json } # yaml_insert : put the block under a # top-level `key:` (created if absent), indented like the key's existing # children. Returns 1 when the key holds an inline value (cannot insert safely). yaml_insert() { strip_block "$1" >"$TMP/stripped" if grep -q "^$2:" "$TMP/stripped" && ! grep -Eq "^$2:[[:space:]]*(#.*)?$" "$TMP/stripped"; then return 1; fi awk -v key="$2" -v blockf="$3" -v b="$BEGIN_MARK" -v e="$END_MARK" ' BEGIN { while ((getline l < blockf) > 0) blk[++n] = l } { line[NR] = $0 } function emit(ind, j) { print ind b; for (j = 1; j <= n; j++) print ind blk[j]; print ind e } END { re = "^" key ":[ \t]*(#.*)?$" for (i = 1; i <= NR; i++) if (line[i] ~ re) { k = i; break } if (!k) { for (i = 1; i <= NR; i++) print line[i]; print key ":"; emit(" "); exit } ind = " " for (i = k + 1; i <= NR; i++) { if (line[i] ~ /^[ \t]*(#.*)?$/) continue if (line[i] ~ /^- /) ind = "" else if (match(line[i], /^ +/)) ind = substr(line[i], 1, RLENGTH) break } for (i = 1; i <= k; i++) print line[i] emit(ind) for (i = k + 1; i <= NR; i++) print line[i] }' "$TMP/stripped" >"$4" } # text_append : replace/append our marked block text_append() { strip_block "$1" >"$3" if [ -s "$3" ] && [ -n "$(tail -c 1 "$3")" ]; then printf '\n' >>"$3"; fi { printf '%s\n' "$BEGIN_MARK"; cat "$2"; printf '%s\n' "$END_MARK"; } >>"$3" } # block_unmerge block_unmerge() { has_block "$1" || return 1 strip_block "$1" >"$TMP/cand" apply "$1" "$TMP/cand" "$2" } # ---------------------------------------------------------------- detection vscode_ext() { # vscode_ext : is the extension in any VS Code-family editor? for d in "$HOME/.vscode/extensions" "$HOME/.vscode-server/extensions" "$HOME/.vscode-insiders/extensions" \ "$HOME/.vscode-oss/extensions" "$HOME/.cursor/extensions" "$HOME/.windsurf/extensions" \ "$HOME/.cursor-server/extensions" "$HOME/.windsurf-server/extensions"; do for x in "$d/$1"-*; do [ -d "$x" ] && return 0; done done return 1 } have() { command -v "$1" >/dev/null 2>&1; } CODEX_DIR=${CODEX_HOME:-$HOME/.codex} OMP_DIR=${PI_CODING_AGENT_DIR:-$HOME/.omp/agent} OPENCODE_FILE=$CONFIG_HOME/opencode/opencode.json [ ! -f "$OPENCODE_FILE" ] && [ -f "$CONFIG_HOME/opencode/opencode.jsonc" ] && OPENCODE_FILE=$CONFIG_HOME/opencode/opencode.jsonc OMP_FILE=$OMP_DIR/models.yml [ ! -f "$OMP_FILE" ] && [ -f "$OMP_DIR/models.yaml" ] && OMP_FILE=$OMP_DIR/models.yaml CRUSH_DIR=${CRUSH_GLOBAL_CONFIG:-$CONFIG_HOME/crush} detect() { case $1 in opencode) have opencode || [ -d "$CONFIG_HOME/opencode" ] ;; codex) have codex || [ -d "$CODEX_DIR" ] ;; aider) have aider || [ -f "$HOME/.aider.conf.yml" ] || [ -f "$HOME/.aider.model.settings.yml" ] ;; crush) have crush || [ -d "$CRUSH_DIR" ] ;; goose) have goose || [ -d "$CONFIG_HOME/goose" ] ;; continue) have cn || [ -d "$HOME/.continue" ] || vscode_ext continue.continue ;; omp) have omp || [ -d "$HOME/.omp" ] ;; pi) [ -d "$HOME/.pi/agent" ] || have pi ;; prime) [ -d "$HOME/.prime/agent" ] || have prime-agent ;; kilo) vscode_ext kilocode.kilo-code || have kilo || [ -d "$CONFIG_HOME/kilo" ] ;; cline) vscode_ext saoudrizwan.claude-dev || have cline ;; roo) vscode_ext rooveterinaryinc.roo-cline ;; claude) have claude || [ -d "$HOME/.claude" ] ;; esac } label() { case $1 in opencode) echo "OpenCode" ;; codex) echo "Codex CLI" ;; aider) echo "Aider" ;; crush) echo "Crush" ;; goose) echo "Goose" ;; continue) echo "Continue" ;; omp) echo "oh-my-pi (omp)" ;; pi) echo "pi" ;; prime) echo "Prime Agent" ;; kilo) echo "Kilo Code" ;; cline) echo "Cline" ;; roo) echo "Roo Code" ;; claude) echo "Claude Code" ;; esac } where() { case $1 in opencode) echo "$OPENCODE_FILE" ;; codex) echo "$CODEX_DIR/config.toml + $CODEX_DIR/goedel.config.toml" ;; aider) echo "$HOME/.aider.model.settings.yml + $HOME/.aider.model.metadata.json" ;; crush) echo "$CRUSH_DIR/crushrc" ;; goose) echo "$CONFIG_HOME/goose/custom_providers/goedel.json" ;; continue) echo "$HOME/.continue/config.yaml + $HOME/.continue/.env" ;; omp) echo "$OMP_FILE" ;; pi) echo "$HOME/.pi/agent/models.json" ;; prime) echo "$HOME/.prime/agent/models.json" ;; kilo|cline|roo) echo "(VS Code settings: manual steps)" ;; claude) echo "$CLAUDE_SETTINGS (used with claude --settings; ~/.claude untouched)" ;; esac } # ---------------------------------------------------------------- harnesses # Each configure_ sets STATUS (configured|unchanged|manual|skipped|snippet|failed) # and LAUNCH (how to start it with our model). # OpenCode. Docs: https://opencode.ai/docs/providers/ (custom provider, # @ai-sdk/openai-compatible) and https://opencode.ai/docs/config/ (global file # ~/.config/opencode/opencode.json, JSONC allowed, {file:~/...} substitution). # Key: "{file:}" so the key never enters opencode.json. configure_opencode() { LAUNCH="opencode -m $PROVIDER_ID/$MODEL" cat >"$TMP/oc.json" < # with base_url, wire_api ("responses" is the only value; "chat" is rejected: # https://github.com/openai/codex/discussions/7782), auth.command for the key; # profiles are $CODEX_HOME/.config.toml, selected with --profile. # Codex therefore needs POST /v1/responses: an API that answers 404 there # (asked with the key; see serves()) is too old for Codex. configure_codex() { LAUNCH="codex --profile $PROVIDER_ID" if ! serves "$BASE_URL/responses"; then warn "Codex only speaks the OpenAI Responses API, and $BASE_URL/responses answers 404; skipping Codex" STATUS=skipped; DETAIL="API has no /v1/responses"; return fi catbin=$(command -v cat); [ -n "$catbin" ] || catbin=/bin/cat cfg=$CODEX_DIR/config.toml # name: "Display name for a custom model provider", the one display field Codex documents for it. cat >"$TMP/cx.toml" <"$TMP/cand2" <"$TMP/ad.yml" <"$TMP/stripped" first=$(grep -v -E '^[[:space:]]*(#.*)?$' "$TMP/stripped" | head -n 1) case $first in ""|"- "*) text_append "$set_file" "$TMP/ad.yml" "$TMP/cand"; apply "$set_file" "$TMP/cand" yaml 600; r1=$? ;; *) snippet "$set_file" "$TMP/ad.yml" "$set_file is not a YAML list; not editing it"; r1=2 ;; esac cat >"$TMP/am.json" <), as the docs do with $(op read ...). configure_crush() { LAUNCH="crush, then pick \"$MODEL_LABEL\" in the model picker (ctrl+l); one-shot: crush run -m $PROVIDER_ID/$MODEL \"...\"" cat >"$TMP/cr.sh" <"$TMP/cand" <"$TMP/ct.yml" <"$TMP/base.yml" cp "$TMP/base.yml" "$TMP/ct-src" else cp "$cfg" "$TMP/ct-src" fi if yaml_insert "$TMP/ct-src" models "$TMP/ct.yml" "$TMP/cand"; then apply "$cfg" "$TMP/cand" yaml; r1=$? else { printf 'models:\n'; sed 's/^/ /' "$TMP/ct.yml"; } >"$TMP/ct-snip" snippet "$cfg" "$TMP/ct-snip" "the models key in $cfg is not a block list; not editing it"; r1=2 fi envf=$HOME/.continue/.env { [ -f "$envf" ] && grep -v '^GOEDEL_API_KEY=' "$envf"; printf 'GOEDEL_API_KEY=%s\n' "$API_KEY"; } >"$TMP/cand" apply "$envf" "$TMP/cand" env 600; r2=$? [ $r1 = 2 ] || [ $r2 = 2 ] && return 2 [ $r1 = 1 ] && [ $r2 = 1 ] && return 1 return 0 } unconfigure_continue() { a=1; block_unmerge "$HOME/.continue/config.yaml" yaml && a=0 envf=$HOME/.continue/.env if [ -f "$envf" ] && grep -q '^GOEDEL_API_KEY=' "$envf"; then grep -v '^GOEDEL_API_KEY=' "$envf" >"$TMP/cand"; apply "$envf" "$TMP/cand" env 600 && a=0 fi return $a } # oh-my-pi / omp (github.com/can1357/oh-my-pi, a fork of Mario Zechner's pi). # Docs: https://github.com/can1357/oh-my-pi/blob/main/docs/models.md and # docs/providers.md: ~/.omp/agent/models.yml, providers. with baseUrl, # api: openai-completions, apiKey ("!cmd" runs a command), models[]. configure_omp() { LAUNCH="omp --model $PROVIDER_ID/$MODEL" cat >"$TMP/om.yml" <"$TMP/stripped" if grep -Eq "^[[:space:]]+$PROVIDER_ID:" "$TMP/stripped"; then snippet "$OMP_FILE" "$TMP/om.yml" "$OMP_FILE already has a '$PROVIDER_ID' provider outside our block; not editing it"; return 2 fi [ -f "$OMP_FILE" ] || : >"$TMP/omp-src" [ -f "$OMP_FILE" ] && cp "$OMP_FILE" "$TMP/omp-src" if yaml_insert "$TMP/omp-src" providers "$TMP/om.yml" "$TMP/cand"; then apply "$OMP_FILE" "$TMP/cand" yaml else { printf 'providers:\n'; sed 's/^/ /' "$TMP/om.yml"; } >"$TMP/om-snip" snippet "$OMP_FILE" "$TMP/om-snip" "the providers key in $OMP_FILE is not a block mapping; not editing it"; return 2 fi } unconfigure_omp() { block_unmerge "$OMP_FILE" yaml; } # pi (Mario Zechner's coding agent, npm @mariozechner/pi-coding-agent). Docs: # https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/models.md # ~/.pi/agent/models.json, providers. {baseUrl, api: openai-completions, # apiKey ("!command" runs at request time), models[]}. # Prime Agent (prime-agent) keeps the same models.json format in ~/.prime/agent, with the same flags. pi_models() { # pi_models LAUNCH="$2 --provider $PROVIDER_ID --model $MODEL" cat >"$TMP/pi.json" < Custom provider; Provider ID: $PROVIDER_ID; Provider API: OpenAI Compatible" ;; *) say " API Provider: OpenAI Compatible" ;; esac say " Base URL: $BASE_URL" say " API Key: your key $(mask "$API_KEY") (copy it with: cat $KEY_FILE)" say " Model ID: $MODEL" say " Context window: $CONTEXT Max output tokens: $MAX_OUTPUT" } configure_kilo() { manual_steps kilo; STATUS=manual; LAUNCH="select model $MODEL in the Kilo Code panel"; } configure_cline() { manual_steps cline; STATUS=manual; LAUNCH="select model $MODEL in the Cline panel"; } configure_roo() { manual_steps roo; STATUS=manual; LAUNCH="select model $MODEL in the Roo Code panel"; } # Claude Code. Docs: https://code.claude.com/docs/en/llm-gateway-connect # (ANTHROPIC_BASE_URL is the host root and Claude Code appends /v1/messages; # apiKeyHelper's output goes out as both Authorization: Bearer and x-api-key), # https://code.claude.com/docs/en/settings (`claude --settings ` is a # per-session layer above ~/.claude/settings.json) and # https://code.claude.com/docs/en/model-config (ANTHROPIC_DEFAULT_*_MODEL, # CLAUDE_CODE_SUBAGENT_MODEL, CLAUDE_CODE_MAX_CONTEXT_TOKENS; and, under "Customize # pinned model display and capabilities", ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL_NAME # and _DESCRIPTION: the pinned model's name and description in the /model picker, which # "also take effect when ANTHROPIC_BASE_URL points to an LLM gateway"; listed too at # https://code.claude.com/docs/en/env-vars). They carry the key type; the model ids do not change. # We write our own settings file and never touch ~/.claude: plain `claude` # keeps the user's login; `claude --settings ` uses Goedel Machines. CLAUDE_SETTINGS=$GOEDEL_DIR/claude-code.json configure_claude() { LAUNCH="claude --settings $CLAUDE_SETTINGS (plain 'claude' keeps your own login)" if ! serves "$BASE_URL/messages"; then warn "Claude Code needs the Anthropic Messages API, and $BASE_URL/messages answers 404; skipping Claude Code" STATUS=skipped; DETAIL="API has no /v1/messages"; return fi cat >"$TMP/cand" </dev/null; say "removed $KEY_FILE"; fi fi say "Backups taken before editing end in .bak-goedel-; earlier ones are kept too." say "Keys stay valid until you delete them at $SITE/keys." exit 0 fi [ -n "$CANDIDATES" ] || { say "No supported coding agents were found. Supported: $ALL_HARNESSES"; say "Install one and run this again, or use --only to write its config anyway."; } SELECTED=$CANDIDATES if [ -n "$CANDIDATES" ] && [ -z "$ONLY" ] && [ $YES = 0 ]; then info "Detected:" i=0 for h in $CANDIDATES; do i=$((i + 1)); printf ' %d) %-16s %s\n' "$i" "$(label "$h")" "$(where "$h")"; done if ask "Configure which? [A]ll, numbers (e.g. 1 3), or n for none: "; then case $REPLY in ""|a|A|all|y|Y|yes) ;; n|N|no|none) SELECTED="" ;; *) SELECTED="" for n in $(printf '%s' "$REPLY" | tr ',' ' '); do j=0; for h in $CANDIDATES; do j=$((j + 1)); [ "$j" = "$n" ] && SELECTED="$SELECTED $h"; done done ;; esac else say "No terminal to ask on; configuring all detected (use --only to narrow)." fi fi NEED_KEY=0 [ -n "$SELECTED" ] && NEED_KEY=1 BASE_URL=${GOEDEL_BASE_URL:-$DEFAULT_BASE_URL}; BASE_URL=${BASE_URL%/} API_KEY="" [ $NEED_KEY = 1 ] && get_key for h in $SELECTED; do info "$(label "$h") ${D}$(where "$h")${N}" STATUS=""; DETAIL=""; LAUNCH=""; SNIPPET_PRINTED=0 "configure_$h"; rc=$? if [ -z "$STATUS" ]; then case $rc in 0) STATUS=configured ;; 1) STATUS=unchanged ;; *) if [ $SNIPPET_PRINTED = 1 ]; then STATUS=snippet; else STATUS=failed; fi ;; esac fi record "$h" "$STATUS" "$LAUNCH" "$DETAIL" done # Live test: a real request, thinking off, 20 tokens. LIVE=skipped if [ $NEED_KEY = 1 ] && [ $DRY_RUN = 0 ] && [ "${GOEDEL_SKIP_LIVE:-0}" != 1 ]; then info "Testing $BASE_URL/chat/completions" (umask 077; printf 'Authorization: Bearer %s\n' "$API_KEY" >"$TMP/auth") printf '{"model":"%s","messages":[{"role":"user","content":"Reply with exactly: ready"}],"max_tokens":20,"stream":false,"chat_template_kwargs":{"thinking":false}}' "$MODEL" >"$TMP/req" code=$(curl -sS --max-time 60 -H @"$TMP/auth" -H 'content-type: application/json' -o "$TMP/body" -w '%{http_code}' --data-binary @"$TMP/req" "$BASE_URL/chat/completions" 2>"$TMP/err") || code=000 if [ "$code" = 200 ]; then if [ $HAVE_JQ = 1 ]; then reply=$(jq -r '.choices[0].message.content // empty' "$TMP/body" 2>/dev/null); else reply=$(json_str content <"$TMP/body"); fi ok "$MODEL answered: $(printf '%s' "$reply" | tr '\n' ' ' | head -c 80)" LIVE=ok else LIVE=failed warn "live test failed: HTTP $code $(head -c 300 "$TMP/body" | mask_stream)$(cat "$TMP/err")" [ "$code" = 401 ] && warn "the key was rejected; delete $KEY_FILE and run the installer again" fi fi say "" info "Summary" if [ -z "$RESULTS" ]; then say " nothing configured"; fi printf '%s' "$RESULTS" | while IFS='|' read -r h st la de; do [ -n "$h" ] || continue case $st in configured|unchanged) printf ' %s%-16s%s %-10s %s\n' "$G" "$(label "$h")" "$N" "$st" "$la" ;; manual) printf ' %-16s %-10s %s\n' "$(label "$h")" "$st" "$la" ;; *) printf ' %s%-16s%s %-10s %s\n' "$Y" "$(label "$h")" "$N" "$st" "${de:-$la}" ;; esac done if [ $NEED_KEY = 1 ]; then say " $KEY_NOTE" [ "$KEY_TIER" = paid ] || say " To switch to a paid key, sign in again and choose Paid: $(new_key_cmd)" fi [ $DRY_RUN = 1 ] && say "Dry run: nothing was changed." [ "$LIVE" = failed ] && exit 1 exit 0