clawdforge/clients/bash/cf
Kayos 347fddea0f clients/bash: cf — single-file bash CLI for clawdforge
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
2026-04-28 22:25:50 -07:00

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