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:
parent
8d1da6e20d
commit
347fddea0f
2 changed files with 392 additions and 0 deletions
118
clients/bash/README.md
Normal file
118
clients/bash/README.md
Normal 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
274
clients/bash/cf
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue