Tiny curl wrapper so cron jobs, deploy scripts, and shell pipes can drive clawdforge without dragging in Python or Go. Surface mirrors the server: cf healthz cf run "<prompt>" [--model] [--system] [--timeout] [--files t1,t2] cf run - # prompt via stdin (long prompts) cf upload <path> [--ttl 3600] cf admin token-mint <name> [--ip-cidrs cidr1,cidr2] cf admin token-list cf admin token-revoke <name> Configuration via env or ~/.config/clawdforge/cf.env: CLAWDFORGE_URL, CLAWDFORGE_TOKEN, CLAWDFORGE_ADMIN_TOKEN Output: JSON to stdout (pipe to jq freely), errors to stderr, exit codes 0/1/2/3/4/5 mapping clearly to transport/usage/auth/4xx/5xx. No deps beyond curl + POSIX tools. jq is optional (only used for prettier error output if available). Smoke-tested against live clawdforge on Lucy: healthz green, /run with small prompt returns parsed JSON in 2-7s, /run with stdin large prompts relies on clawdforge's server-side stdin path (>64KB), admin token-list returns the cauldron token row. Build/install: sudo install -m 755 clients/bash/cf /usr/local/bin/cf
274 lines
8.4 KiB
Bash
Executable file
274 lines
8.4 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# cf — bash CLI for clawdforge
|
|
#
|
|
# Usage:
|
|
# cf healthz
|
|
# cf run "<prompt>" [--model sonnet] [--system "..."] [--timeout 60] [--files token1,token2]
|
|
# cf run - # read prompt from stdin (any size)
|
|
# cf upload <path> [--ttl 3600]
|
|
# cf admin token-mint <name> [--ip-cidrs cidr1,cidr2]
|
|
# cf admin token-list
|
|
# cf admin token-revoke <name>
|
|
#
|
|
# 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 <prompt|-> [--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 <path> [--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 <name> [--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 <name>" 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
|