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
This commit is contained in:
Kayos 2026-04-28 22:25:50 -07:00
parent 8d1da6e20d
commit 347fddea0f
2 changed files with 392 additions and 0 deletions

118
clients/bash/README.md Normal file
View file

@ -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 "<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>
```
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).

274
clients/bash/cf Executable file
View file

@ -0,0 +1,274 @@
#!/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