From 347fddea0f9e57742f6a2da735e0df5d9d7baa18 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 22:25:50 -0700 Subject: [PATCH] =?UTF-8?q?clients/bash:=20cf=20=E2=80=94=20single-file=20?= =?UTF-8?q?bash=20CLI=20for=20clawdforge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "" [--model] [--system] [--timeout] [--files t1,t2] cf run - # prompt via stdin (long prompts) cf upload [--ttl 3600] cf admin token-mint [--ip-cidrs cidr1,cidr2] cf admin token-list cf admin token-revoke 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 --- clients/bash/README.md | 118 ++++++++++++++++++ clients/bash/cf | 274 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 clients/bash/README.md create mode 100755 clients/bash/cf diff --git a/clients/bash/README.md b/clients/bash/README.md new file mode 100644 index 0000000..8c14d1c --- /dev/null +++ b/clients/bash/README.md @@ -0,0 +1,118 @@ +# cf — bash CLI for clawdforge + +Single-file shell client for [clawdforge](../../README.md). Wraps `curl` so you +can drive clawdforge from cron jobs, deploy scripts, one-off pipes, or any +shell where pulling in a Python/Go runtime is overkill. + +## Install + +```sh +sudo install -m 755 cf /usr/local/bin/cf +``` + +Or just symlink into `~/bin`: + +```sh +ln -s "$(pwd)/cf" ~/bin/cf +``` + +No dependencies beyond `curl` + standard POSIX tools. `jq` is optional — +used for prettier error output if available, never required. + +## Configuration + +Reads from env or `~/.config/clawdforge/cf.env` (env wins on conflict): + +| Variable | Default | Purpose | +|---|---|---| +| `CLAWDFORGE_URL` | `http://192.168.0.5:8800` | clawdforge base URL | +| `CLAWDFORGE_TOKEN` | — | per-app bearer; required for `/run`, `/files` | +| `CLAWDFORGE_ADMIN_TOKEN` | — | bootstrap admin bearer; required for `cf admin *` | + +Example `cf.env`: + +```sh +CLAWDFORGE_URL=http://192.168.0.5:8800 +CLAWDFORGE_TOKEN=cf_AbCd... +CLAWDFORGE_ADMIN_TOKEN=... +``` + +## Commands + +``` +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 +``` + +Output is JSON to stdout — pipe to `jq` for shaping. Errors go to stderr. + +### Exit codes + +| Code | Meaning | +|---|---| +| 0 | ok | +| 1 | curl/transport failure | +| 2 | usage error (bad/missing args) | +| 3 | missing required token | +| 4 | HTTP 4xx (auth, not found, bad request) | +| 5 | HTTP 5xx (server / claude failure) | + +## Examples + +### Health check in a cron preamble + +```sh +cf healthz | jq -e '.claude_present' >/dev/null || { echo "clawdforge not ready"; exit 1; } +``` + +### One-shot prompt with JSON output + +```sh +cf run 'Reply with JSON: {"city":"Lake Elsinore","feel":"summer"}' --model sonnet \ + | jq -r '.result.city' +``` + +### Long prompt via stdin (anything bigger than the OS argv limit) + +```sh +cat <<'PROMPT' | cf run - --timeout 180 +You are a precise recipe parser. Given the following text… +… +PROMPT +``` + +The bash CLI doesn't do anything special for size — it relies on +clawdforge's server-side stdin path for prompts > 64KB. + +### Upload a file then attach it to a run + +```sh +ft=$(cf upload /tmp/recipe.png --ttl 3600 | jq -r '.file_token') +cf run "extract the recipe from this image as JSON" --files "$ft" --timeout 120 +``` + +### Mint a per-app token + +```sh +cf admin token-mint johnny5 --ip-cidrs 172.24.0.0/16 +# → {"name":"johnny5","token":"cf_...","ip_cidrs":["172.24.0.0/16"]} +``` + +## Pattern: bash-only no-jq fallback + +If you can't install `jq`, parse with `read`: + +```sh +read -r status size < <(cf upload "$path" | grep -o '"file_token":"[^"]*"\|"size":[0-9]*' | tr '\n' ' ') +``` + +But really, install `jq`. + +## License + +Same as the rest of clawdforge (MIT). diff --git a/clients/bash/cf b/clients/bash/cf new file mode 100755 index 0000000..eba6d9e --- /dev/null +++ b/clients/bash/cf @@ -0,0 +1,274 @@ +#!/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