#!/bin/bash # Allgate setup for macOS / Linux: Bash 3.2+, curl and standard system tools. # Read this file before running it. Keys are prompted locally, never in a URL. set -eu set +x umask 077 export LC_ALL=C fail() { printf 'Setup failed: %s\n' "$1" >&2; exit 1; } usage() { printf '%s\n' 'Configure Codex or Claude Code for Allgate. No model calls are made.' \ 'Options: --agent codex|claude-code --model MODEL --base-url HTTPS_ORIGIN' \ ' --key-file PRIVATE_FILE | --restore EXACT_BACKUP_DIRECTORY' } agent='' model='' base_url='https://api.allgate.ai' key_file='' restore='' while [ "$#" -gt 0 ]; do option=$1 case "$option" in -h|--help) usage; exit 0 ;; --agent|--model|--base-url|--key-file|--restore) [ "$#" -ge 2 ] || fail 'An option is missing its value.' value=$2; shift 2 ;; --agent=*|--model=*|--base-url=*|--key-file=*|--restore=*) value=${option#*=}; option=${option%%=*}; shift ;; *) fail 'Unknown option. Run with --help for usage.' ;; esac case "$option" in --agent) agent=$value ;; --model) model=$value ;; --base-url) base_url=$value ;; --key-file) key_file=$value ;; --restore) restore=$value ;; esac done for tool in awk cat chmod cmp date mkdir mktemp mv rm stat; do command -v "$tool" >/dev/null 2>&1 || fail 'A required standard system tool is unavailable.' done prompt() { printf '%s' "$1" >/dev/tty || fail 'An interactive terminal is required.' IFS= read -r reply "$temporary" && chmod 600 "$temporary" && mv -f "$temporary" "$target"; then return 0 fi rm -f "$temporary" return 1 } [ -n "$agent" ] || { prompt 'Agent (codex / claude-code): '; agent=$reply; } case "$agent" in codex|claude-code) ;; *) fail 'Choose codex or claude-code.' ;; esac root=$(absolute_path "${ALLGATE_SETUP_HOME:-$HOME}") state=$root/.config/allgate/agents/$agent if [ "$agent" = codex ]; then config_dir=$root/.codex [ -n "${ALLGATE_SETUP_HOME:-}" ] || config_dir=$(absolute_path "${CODEX_HOME:-$config_dir}") config=$config_dir/allgate.config.toml else config_dir=$root/.claude [ -n "${ALLGATE_SETUP_HOME:-}" ] || config_dir=$(absolute_path "${CLAUDE_CONFIG_DIR:-$config_dir}") config=$config_dir/settings.json fi token=$state/token helper=$state/token.sh backups=$state/backups for target in "$root/.config" "$root/.config/allgate" "$root/.config/allgate/agents" "$config" "$token" "$helper" "$config_dir" "$state" "$backups"; do private_path "$target"; done for target in "$config" "$token" "$helper"; do [ ! -e "$target" ] || [ -f "$target" ] || fail 'A configuration or credential path is not a regular file.' done work=$(mktemp -d "${TMPDIR:-/tmp}/allgate-setup.XXXXXXXX") || fail 'Could not create private temporary files.' chmod 700 "$work" cleanup() { rm -rf "$work"; } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM # Retain JSON scalar spelling (including large numbers), decode escaped keys # and model IDs, and never interpret data as shell or awk code. LC_ALL=C makes # UTF-8 validation byte-exact on both operating systems. cat >"$work/json.awk" <<'AWK' function bad(message) { failed=1 print "Setup failed: " (message ? message : "Invalid JSON in local configuration / catalogue. Nothing was changed.") > "/dev/stderr" exit 2 } function ws() { while (substr(doc,pos,1) ~ /^[ \t\r\n]$/) pos++ } function hex4( text,i,n,c) { text=substr(doc,pos,4) if (length(text)!=4 || text ~ /[^0-9a-fA-F]/) bad() n=0 for (i=1;i<=4;i++) { c=index("0123456789abcdef",tolower(substr(text,i,1)))-1; n=n*16+c } pos+=4 return n } function utf8(n) { if (n<128) return sprintf("%c",n) if (n<2048) return sprintf("%c%c",192+int(n/64),128+n%64) if (n<65536) return sprintf("%c%c%c",224+int(n/4096),128+int(n/64)%64,128+n%64) return sprintf("%c%c%c%c",240+int(n/262144),128+int(n/4096)%64,128+int(n/64)%64,128+n%64) } function string( out,c,e,n,lo,b,j,count,min,max) { if (substr(doc,pos++,1)!="\"") bad() out="" while (pos<=length(doc)) { c=substr(doc,pos++,1) if (c=="\"") return out b=ord[c] if (b<32) bad() if (c=="\\") { e=substr(doc,pos++,1) if (e=="\"" || e=="\\" || e=="/") out=out e else if (e=="b") out=out sprintf("%c",8) else if (e=="f") out=out sprintf("%c",12) else if (e=="n") out=out "\n" else if (e=="r") out=out "\r" else if (e=="t") out=out "\t" else if (e=="u") { n=hex4() if (n>=55296 && n<=56319) { if (substr(doc,pos,2)!="\\u") bad() pos+=2; lo=hex4() if (lo<56320 || lo>57343) bad() n=65536+(n-55296)*1024+lo-56320 } else if (n>=56320 && n<=57343) bad() out=out utf8(n) } else bad() } else if (b<128) out=out c else { count=(b>=194 && b<=223 ? 1 : b>=224 && b<=239 ? 2 : b>=240 && b<=244 ? 3 : -1) if (count<0) bad() out=out c for (j=1;j<=count;j++) { c=substr(doc,pos++,1); n=ord[c]; min=128; max=191 if (j==1 && b==224) min=160 if (j==1 && b==237) max=159 if (j==1 && b==240) min=144 if (j==1 && b==244) max=143 if (c=="" || nmax) bad() out=out c } } } bad() } function link(parent,child,name) { if (last[parent]) nextchild[last[parent]]=child; else first[parent]=child last[parent]=child; key[child]=name } function parse(depth, id,c,start,name,child,text) { if (depth>128) bad() ws(); id=++nodes; start=pos; c=substr(doc,pos,1) if (c=="{" || c=="[") { type[id]=(c=="{" ? "o" : "a"); pos++; ws() if (substr(doc,pos,1)==(c=="{" ? "}" : "]")) { pos++; return id } while (1) { if (c=="{") { ws(); name=string(); ws() if (seen[id SUBSEP name]++ || substr(doc,pos++,1)!=":") bad() } else name="" child=parse(depth+1); link(id,child,name); ws(); text=substr(doc,pos++,1) if (text==(c=="{" ? "}" : "]")) break if (text!=",") bad() } } else if (c=="\"") { type[id]="s"; value[id]=string(); raw[id]=substr(doc,start,pos-start) } else if (match(substr(doc,pos),/^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/)) { type[id]="n"; raw[id]=substr(doc,pos,RLENGTH); pos+=RLENGTH } else if (substr(doc,pos,4)=="true" || substr(doc,pos,4)=="null") { type[id]=(c=="t" ? "b" : "z"); raw[id]=substr(doc,pos,4); pos+=4 } else if (substr(doc,pos,5)=="false") { type[id]="b"; raw[id]="false"; pos+=5 } else bad() return id } function quote(text, result,i,c,b) { result="\"" for (i=1;i<=length(text);i++) { c=substr(text,i,1); b=ord[c] if (c=="\"" || c=="\\") result=result "\\" c else if (b<32) result=result sprintf("\\u%04x",b) else result=result c } return result "\"" } function get(parent,name, child) { for (child=first[parent];child;child=nextchild[child]) if (key[child]==name) return child return 0 } function setstring(parent,name,text, child) { child=get(parent,name) if (!child) { child=++nodes; link(parent,child,name) } type[child]="s"; value[child]=text; raw[child]=quote(text) } function emit(id, result,child,separator) { if (type[id]!="o" && type[id]!="a") return raw[id] result=(type[id]=="o" ? "{" : "["); separator="" for (child=first[id];child;child=nextchild[child]) if (!omit[child]) { result=result separator (type[id]=="o" ? quote(key[child]) ":" : "") emit(child); separator="," } return result (type[id]=="o" ? "}" : "]") } BEGIN { for (i=0;i<256;i++) ord[sprintf("%c",i)]=i; mode=ENVIRON["ALLGATE_JSON_MODE"] } { doc=doc $0 "\n"; if (length(doc)>33554432) bad() } END { if (failed) exit 2 if (mode=="key") { sub(/^[[:space:]]+/,"",doc); sub(/[[:space:]]+$/,"",doc) doc=quote(doc); pos=1; credential=string() if (credential=="" || credential ~ /[[:space:][:cntrl:]]/) bad("The key must be a nonempty token without whitespace. Nothing was changed.") split("133 160 5760 8192 8193 8194 8195 8196 8197 8198 8199 8200 8201 8202 8232 8233 8239 8287 12288",spaces," ") for (i=1;i<=19;i++) if (index(credential,utf8(spaces[i]+0))) bad("The key must be a nonempty token without whitespace. Nothing was changed.") print credential exit } if (substr(doc,1,3)==sprintf("%c%c%c",239,187,191)) doc=substr(doc,4) pos=1; root=parse(0); ws(); if (pos<=length(doc)) bad() agent=ENVIRON["ALLGATE_JSON_AGENT"]; model=ENVIRON["ALLGATE_JSON_MODEL"] origin=ENVIRON["ALLGATE_JSON_ORIGIN"]; helper=ENVIRON["ALLGATE_JSON_HELPER"] if (mode=="catalogue") { if (type[root]!="a") bad() vendor=(agent=="codex" ? "openai" : "anthropic") for (child=first[root];child;child=nextchild[child]) { if (type[child]!="o") bad() m=get(child,"model"); v=get(child,"vendor"); a=get(child,"available"); c=get(child,"category") if (type[m]=="s" && type[v]=="s" && type[a]=="b" && raw[a]=="true" && type[c]=="s" && value[c]=="chat" && tolower(value[v])==vendor && value[m]!~/^allgate-sandbox-/) { if (value[m]=="" || value[m] ~ /[[:cntrl:]]/) bad("The model ID contains control characters or is empty.") print value[m] } } } else if (mode=="claude") { if (type[root]!="o") bad("Claude settings must contain a JSON object and an object-valued env field. Nothing was changed.") env=get(root,"env") if (env && type[env]!="o") bad("Claude settings must contain a JSON object and an object-valued env field. Nothing was changed.") if (!env) { env=++nodes; type[env]="o"; link(root,env,"env") } split("ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY CLAUDE_CODE_USE_AWS",remove," ") for (i=1;i<=6;i++) { child=get(env,remove[i]); if (child) omit[child]=1 } setstring(env,"ANTHROPIC_BASE_URL",origin); setstring(root,"model",model) setstring(root,"apiKeyHelper",ENVIRON["ALLGATE_JSON_COMMAND"]) print emit(root) } else if (mode=="codex") { print "# Managed by Allgate setup. Base config and auth.json are preserved." print "model = " quote(model) "\nmodel_provider = \"allgate_setup\"\n" print "[model_providers.allgate_setup]\nname = \"Allgate\"\nbase_url = " quote(origin "/v1") "\nwire_api = \"responses\"\n" print "[model_providers.allgate_setup.auth]\ncommand = \"/bin/sh\"\nargs = [" quote(helper) "]\ntimeout_ms = 5000" } else if (mode=="manifest") { printf "{\"agent\":%s,\"targets\":{\"config\":%s,\"token\":%s,\"helper\":%s},\"existing\":[",quote(agent),quote(ENVIRON["ALLGATE_JSON_CONFIG"]),quote(ENVIRON["ALLGATE_JSON_TOKEN"]),quote(helper) separator="" split("config token helper",names," ") for (i=1;i<=3;i++) if (ENVIRON["ALLGATE_JSON_HAS_" names[i]]=="1") { printf "%s%s",separator,quote(names[i]); separator="," } print "]}" } else if (mode=="restore") { t=get(root,"targets"); a=get(root,"agent"); existing=get(root,"existing") if (type[root]!="o" || type[a]!="s" || value[a]!=agent || type[t]!="o" || type[existing]!="a") bad("Backup does not match these configuration paths.") split("config token helper",names," "); count=0 for (child=first[t];child;child=nextchild[child]) count++ if (count!=3) bad("Backup does not match these configuration paths.") for (i=1;i<=3;i++) { child=get(t,names[i]); if (type[child]!="s" || value[child]!=ENVIRON["ALLGATE_JSON_" toupper(names[i])]) bad("Backup does not match these configuration paths.") } for (child=first[existing];child;child=nextchild[child]) { name=value[child] if (type[child]!="s" || (name!="config" && name!="token" && name!="helper") || restored[name]++) bad("Backup manifest contains an unexpected file.") print name } } else bad() } AWK printf '{}\n' >"$work/empty.json" json() { ALLGATE_JSON_MODE=$1 ALLGATE_JSON_AGENT=$agent ALLGATE_JSON_MODEL=$model \ ALLGATE_JSON_ORIGIN=${origin:-} ALLGATE_JSON_HELPER=$helper \ ALLGATE_JSON_CONFIG=$config ALLGATE_JSON_TOKEN=$token \ ALLGATE_JSON_COMMAND="/bin/sh $(shell_quote "$helper")" \ ALLGATE_JSON_HAS_config=${has_config:-0} ALLGATE_JSON_HAS_token=${has_token:-0} \ ALLGATE_JSON_HAS_helper=${has_helper:-0} awk -f "$work/json.awk" "$2" } target_path() { case "$1" in config) printf '%s\n' "$config" ;; token) printf '%s\n' "$token" ;; helper) printf '%s\n' "$helper" ;; esac } restore_files() { local name target failed=0 for name in config token helper; do target=$(target_path "$name") if [ -f "$work/exists-$name" ]; then write_private "$target" "$backup/$name" || failed=1 else private_path "$target" rm -f "$target" || failed=1 fi done [ "$failed" -eq 0 ] } if [ -n "$restore" ]; then backup=$(absolute_path "$restore") [ "${backup%/*}" = "$backups" ] && [ ! -L "$backup" ] && [ -d "$backup" ] || fail 'Use the exact backup directory printed for this agent.' private_path "$backup/manifest.json" [ -f "$backup/manifest.json" ] || fail 'Backup does not match these configuration paths.' json restore "$backup/manifest.json" >"$work/existing" || exit 1 while IFS= read -r name; do private_path "$backup/$name" [ -f "$backup/$name" ] || fail 'A required backup file is missing. Nothing was changed.' : >"$work/exists-$name" done <"$work/existing" restore_files || fail 'Could not restore all files. Keep this backup and retry.' printf 'Restored configuration and credential files from %s\n' "$backup" exit 0 fi while [ "${base_url%/}" != "$base_url" ]; do base_url=${base_url%/}; done case "$base_url" in https://*) ;; *) fail 'Base URL must be an HTTPS API origin, optionally ending in /v1, without credentials or query parameters.' ;; esac origin=${base_url%/v1} origin_pattern='^https://([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(:[0-9]+)?$' [[ $origin =~ $origin_pattern ]] || fail 'Base URL must be an HTTPS API origin, optionally ending in /v1, without credentials or query parameters.' authority=${origin#https://} case "$authority" in \[*\]) ;; *:*) port=${authority##*:}; [ "${#port}" -le 5 ] && [ "$((10#$port))" -ge 1 ] && [ "$((10#$port))" -le 65535 ] || fail 'Base URL must be an HTTPS API origin with a valid port.' ;; esac command -v curl >/dev/null 2>&1 || fail 'curl is required to check the public model catalogue.' curl --fail --silent --show-error --max-time 30 --proto '=https' "$origin/api/v1/public/models" >"$work/catalogue.json" 2>/dev/null || fail 'Could not load the public model catalogue. Nothing was changed. Check the API URL and retry.' json catalogue "$work/catalogue.json" >"$work/choices" || exit 1 [ -s "$work/choices" ] || fail 'No available chat model for this agent is listed. Nothing was changed.' if [ -z "$model" ]; then printf '%s\n' 'Available model IDs:' cat "$work/choices" prompt 'Model ID: '; model=$reply fi case "$model" in *[[:cntrl:]]*) fail 'The model ID contains control characters.' ;; esac found=0 while IFS= read -r choice; do [ "$choice" != "$model" ] || found=1; done <"$work/choices" [ "$found" -eq 1 ] || fail 'That model is not currently listed as available for this agent. Nothing was changed.' if [ "$agent" = codex ]; then if [ -f "$config_dir/config.toml" ]; then if awk 'index($0,"allgate_setup") { found=1 } END { exit !found }' "$config_dir/config.toml"; then fail 'Your base Codex configuration already mentions the reserved allgate_setup provider. Nothing was changed. Review that entry before using setup.' fi fi json codex "$work/empty.json" >"$work/config" || exit 1 else settings=$work/empty.json [ ! -e "$config" ] || settings=$config json claude "$settings" >"$work/config" || exit 1 fi if [ -n "$key_file" ]; then key_file=$(absolute_path "$key_file") private_path "$key_file" [ -f "$key_file" ] || fail 'The key file must be a readable regular file.' if mode=$(stat -c '%a' "$key_file" 2>/dev/null); then : else mode=$(stat -f '%Lp' "$key_file" 2>/dev/null) || fail 'Could not check key file permissions.'; fi case "$mode" in ''|*[!0-7]*) fail 'Could not check key file permissions.' ;; esac [ "$((8#$mode & 077))" -eq 0 ] || fail 'The key file must be private: chmod 600 your-key-file.' key_source=$key_file else printf '%s' 'Allgate API key (hidden): ' >/dev/tty || fail 'An interactive terminal is required.' IFS= read -r -s key /dev/tty printf '%s\n' "$key" >"$work/key-input" unset key key_source=$work/key-input fi json key "$key_source" >"$work/token" || exit 1 printf '#!/bin/sh\nset -eu\nexec cat %s\n' "$(shell_quote "$token")" >"$work/helper" changed=0 has_config=0 has_token=0 has_helper=0 for name in config token helper; do target=$(target_path "$name") if [ -e "$target" ]; then [ -f "$target" ] || fail 'A configuration or credential path is not a regular file.' : >"$work/exists-$name" case "$name" in config) has_config=1 ;; token) has_token=1 ;; helper) has_helper=1 ;; esac fi cmp -s "$target" "$work/$name" || changed=1 done if [ "$changed" -eq 1 ]; then mkdir -p "$state" "$backups" chmod 700 "$state" "$backups" backup=$(mktemp -d "$backups/$(date -u +%Y%m%dT%H%M%SZ)-XXXXXXXX") chmod 700 "$backup" for name in config token helper; do [ ! -f "$work/exists-$name" ] || write_private "$backup/$name" "$(target_path "$name")" done json manifest "$work/empty.json" >"$work/manifest" write_private "$backup/manifest.json" "$work/manifest" printf 'Backup: %s\n' "$backup" for name in config token helper; do if ! write_private "$(target_path "$name")" "$work/$name"; then restore_files || fail 'Writing configuration and rollback failed. Keep the backup directory and restore it.' fail 'Could not write configuration; the previous files were restored.' fi done printf 'Restore: bash <(curl -fsSL https://allgate.ai/setup/allgate-setup.sh) --agent %s --restore %s\n' "$agent" "$(shell_quote "$backup")" else chmod 600 "$config" "$token" "$helper" chmod 700 "$state" "$backups" printf '%s\n' 'Configuration already matches; no new backup was created.' fi /bin/sh "$helper" >"$work/credential-check" || fail 'The agent credential helper could not read the saved key.' cmp -s "$work/token" "$work/credential-check" || fail 'The saved agent credential did not match the supplied key.' printf '%s\n' 'API key configured and credential helper verified. The key stays in a protected local file.' printf 'Configured %s with model %s.\n' "$agent" "$model" if [ "$agent" = codex ]; then printf '%s\n' 'Start: codex --profile allgate' 'Requires current Codex with separate profile files and command-backed provider authentication; configuration verified with 0.163.' executable=codex else printf '%s\n' 'Start: claude' 'Requires a current Claude Code release. Run /status to confirm the API origin and credential helper.' \ 'If existing shell credentials or provider variables override settings, unset them in that terminal before starting Claude Code.' executable=claude fi printf '%s\n' 'No model request was sent. Check the selected model and your Allgate request logs after your first message.' if ! command -v "$executable" >/dev/null 2>&1; then printf '%s\n' 'The agent CLI is not installed on PATH. Install it from its official documentation; setup did not install packages.' fi