#!/usr/bin/env bash # cf — bash CLI for clawdforge # # Usage: # cf healthz # cf run "" [--model sonnet] [--system "..."] [--timeout 60] [--files token1,token2] # cf run - # read prompt from stdin (any size) # cf upload [--ttl 3600] # cf admin token-mint [--ip-cidrs cidr1,cidr2] # cf admin token-list # cf admin token-revoke # # Configuration (env or ~/.config/clawdforge/cf.env, env wins): # CLAWDFORGE_URL — default http://192.168.0.5:8800 # CLAWDFORGE_TOKEN — required for /run + /files # CLAWDFORGE_ADMIN_TOKEN — required for admin/* subcommands # # Output: # - successful command → response JSON to stdout (pipe to jq freely) # - errors → message to stderr, non-zero exit code # # Exit codes: # 0 ok # 1 generic error / curl failure # 2 bad usage # 3 missing token # 4 HTTP 4xx (auth / not-found / bad-request) # 5 HTTP 5xx (server / claude failure) set -euo pipefail # ---------- config loading ------------------------------------------------- _CFG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/clawdforge/cf.env" if [[ -f "$_CFG_FILE" ]]; then # shellcheck disable=SC1090 set -a; . "$_CFG_FILE"; set +a fi CLAWDFORGE_URL="${CLAWDFORGE_URL:-http://192.168.0.5:8800}" CLAWDFORGE_URL="${CLAWDFORGE_URL%/}" # ---------- helpers -------------------------------------------------------- _die() { echo "cf: $*" >&2; exit "${2:-1}"; } _need_token() { [[ -n "${CLAWDFORGE_TOKEN:-}" ]] || _die "CLAWDFORGE_TOKEN not set (env or ~/.config/clawdforge/cf.env)" 3 } _need_admin_token() { [[ -n "${CLAWDFORGE_ADMIN_TOKEN:-}" ]] || _die "CLAWDFORGE_ADMIN_TOKEN not set" 3 } # Fire a curl request, capture body + status, exit on HTTP error. # Args: METHOD URL TOKEN [DATA_OR_- [CONTENT_TYPE]] _request() { local method="$1" url="$2" token="$3" data="${4:-}" ctype="${5:-application/json}" local body status local tmp="" tmp="$(mktemp)" trap '[[ -n "${tmp:-}" ]] && rm -f "$tmp"' RETURN local -a curl_args=( -sS -X "$method" "$url" -H "Authorization: Bearer $token" -H "Accept: application/json" -w '\n__cf_status__=%{http_code}' --max-time 600 ) if [[ -n "$data" ]]; then if [[ "$data" == "-" ]]; then cat > "$tmp" curl_args+=(-H "Content-Type: $ctype" --data-binary "@$tmp") else curl_args+=(-H "Content-Type: $ctype" --data-binary "$data") fi fi local resp resp="$(curl "${curl_args[@]}" 2>&1)" || _die "curl failed: $resp" 1 status="${resp##*__cf_status__=}" body="${resp%__cf_status__=*}" body="${body%$'\n'}" # trim trailing newline before status marker if [[ "$status" -ge 200 && "$status" -lt 300 ]]; then printf '%s\n' "$body" return 0 fi echo "cf: HTTP $status" >&2 printf '%s\n' "$body" >&2 if [[ "$status" -ge 400 && "$status" -lt 500 ]]; then exit 4; fi exit 5 } # Build a JSON object from --key value flags (each value is JSON-quoted as string). # Numbers passed as $key=number are kept numeric. Booleans true/false kept bool. # Lists are NOT supported — cmd_run handles its files-array specially. _json_obj_from_assoc() { local first=1 out='{' for kv in "$@"; do local k="${kv%%=*}" v="${kv#*=}" if [[ "$first" == 0 ]]; then out+=','; fi first=0 out+="\"$k\":" if [[ "$v" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then out+="$v" elif [[ "$v" == "true" || "$v" == "false" ]]; then out+="$v" else # JSON-escape: backslash, quote, newline, tab, CR local esc="${v//\\/\\\\}" esc="${esc//\"/\\\"}" esc="${esc//$'\n'/\\n}" esc="${esc//$'\r'/\\r}" esc="${esc//$'\t'/\\t}" out+="\"$esc\"" fi done out+='}' printf '%s\n' "$out" } # Print the error body's "error" field if it's JSON, else the whole body. _extract_error() { local body="$1" if command -v jq >/dev/null 2>&1; then jq -r '.error // .detail // .' <<<"$body" 2>/dev/null || printf '%s' "$body" else printf '%s' "$body" fi } # ---------- subcommands ---------------------------------------------------- cmd_healthz() { _request GET "$CLAWDFORGE_URL/healthz" "${CLAWDFORGE_TOKEN:-${CLAWDFORGE_ADMIN_TOKEN:-}}" } cmd_run() { _need_token local prompt="" model="" system="" timeout_secs="" files="" if [[ $# -lt 1 ]]; then _die "usage: cf run [--model] [--system] [--timeout] [--files]" 2; fi prompt="$1"; shift if [[ "$prompt" == "-" ]]; then prompt="$(cat)"; fi while [[ $# -gt 0 ]]; do case "$1" in --model) model="$2"; shift 2;; --system) system="$2"; shift 2;; --timeout) timeout_secs="$2"; shift 2;; --files) files="$2"; shift 2;; *) _die "unknown flag: $1" 2;; esac done # Build JSON safely without depending on jq for input local args=("prompt=$prompt") [[ -n "$model" ]] && args+=("model=$model") [[ -n "$system" ]] && args+=("system=$system") [[ -n "$timeout_secs" ]] && args+=("timeout_secs=$timeout_secs") if [[ -n "$files" ]]; then # Build files array — keep this branch pure-bash to avoid jq dep local farr='[' local first=1 IFS=, for ft in $files; do [[ "$first" == 0 ]] && farr+=',' farr+="\"$ft\"" first=0 done farr+=']' # Compose final body manually since args helper doesn't do arrays local body body="$(_json_obj_from_assoc "${args[@]}")" body="${body%\}},\"files\":$farr}" body="${body//\}\}/\}}" # tidy any double-close edge case # Guard: if substitution didn't apply correctly, fall back to manual splice if [[ "$body" != *'"files":'* ]]; then body="${body%\}},\"files\":$farr}" fi _request POST "$CLAWDFORGE_URL/run" "$CLAWDFORGE_TOKEN" "$body" else local body; body="$(_json_obj_from_assoc "${args[@]}")" _request POST "$CLAWDFORGE_URL/run" "$CLAWDFORGE_TOKEN" "$body" fi } cmd_upload() { _need_token local path="${1:-}" ttl="" [[ -n "$path" && -f "$path" ]] || _die "usage: cf upload [--ttl 3600]" 2 shift while [[ $# -gt 0 ]]; do case "$1" in --ttl) ttl="$2"; shift 2;; *) _die "unknown flag: $1" 2;; esac done local -a curl_args=( -sS -X POST "$CLAWDFORGE_URL/files" -H "Authorization: Bearer $CLAWDFORGE_TOKEN" -H "Accept: application/json" -w '\n__cf_status__=%{http_code}' -F "file=@$path" ) [[ -n "$ttl" ]] && curl_args+=(-F "ttl_secs=$ttl") local resp; resp="$(curl "${curl_args[@]}")" || _die "curl failed" 1 local status="${resp##*__cf_status__=}" local body="${resp%__cf_status__=*}"; body="${body%$'\n'}" if [[ "$status" -ge 200 && "$status" -lt 300 ]]; then printf '%s\n' "$body" else echo "cf: HTTP $status" >&2; printf '%s\n' "$body" >&2 [[ "$status" -lt 500 ]] && exit 4 || exit 5 fi } cmd_admin() { local sub="${1:-}"; shift || true _need_admin_token case "$sub" in token-mint) local name="${1:-}"; shift || true [[ -n "$name" ]] || _die "usage: cf admin token-mint [--ip-cidrs cidr1,cidr2]" 2 local cidrs="" while [[ $# -gt 0 ]]; do case "$1" in --ip-cidrs) cidrs="$2"; shift 2;; *) _die "unknown flag: $1" 2;; esac done local body if [[ -n "$cidrs" ]]; then local arr='[' first=1 IFS=, for c in $cidrs; do [[ "$first" == 0 ]] && arr+=','; first=0 arr+="\"$c\"" done; arr+=']' body="{\"name\":\"$name\",\"ip_cidrs\":$arr}" else body="{\"name\":\"$name\",\"ip_cidrs\":[]}" fi _request POST "$CLAWDFORGE_URL/admin/tokens" "$CLAWDFORGE_ADMIN_TOKEN" "$body" ;; token-list) _request GET "$CLAWDFORGE_URL/admin/tokens" "$CLAWDFORGE_ADMIN_TOKEN" ;; token-revoke) local name="${1:-}" [[ -n "$name" ]] || _die "usage: cf admin token-revoke " 2 _request DELETE "$CLAWDFORGE_URL/admin/tokens/$name" "$CLAWDFORGE_ADMIN_TOKEN" ;; *) _die "unknown admin subcommand: $sub (token-mint|token-list|token-revoke)" 2;; esac } cmd_help() { sed -n '2,/^set -euo/{/^set -euo/q;p}' "${BASH_SOURCE[0]}" | sed 's/^# \?//' } # ---------- dispatch ------------------------------------------------------- cmd="${1:-help}" case "$cmd" in -h|--help|help) cmd_help;; healthz) shift; cmd_healthz "$@";; run) shift; cmd_run "$@";; upload) shift; cmd_upload "$@";; admin) shift; cmd_admin "$@";; *) _die "unknown command: $cmd (healthz|run|upload|admin|help)" 2;; esac