#!/usr/bin/env bash
# foldrender: publish an HTML file and get a link you can share, and read a
# published page back as text.
#
# Needs only bash, curl (7.55 or later, for -H @file) and coreutils, so it runs
# on macOS's bash 3.2 and on Linux. `publish` and `edit` write nothing to stdout
# but the page's address, one line a page, so an agent can capture it; on
# stderr, first one line that says what happened (a new page, an update in
# place, or no new page because the account has this page already), then
# notes, warnings and the next commands, one line each. Other commands print
# to stdout, briefly by default; --json prints the API's answer as it is.
#
# FOLDRENDER_TOKEN API token; takes precedence over any saved account.
# FOLDRENDER_ACCOUNT A saved account's GitHub login (as --as LOGIN): this
# command runs as that account, whichever is the
# default (foldrender auth switch).
# FOLDRENDER_API API base URL (default https://foldrender.dev/artifacts,
# where the app is; its API is under /api there).
# FOLDRENDER_NO_BROWSER Set to 1 to print the sign-in link, not open it.
# FOLDRENDER_READ_MAX_TOKENS How much `read` prints before it stops, says how
# to go on and lists the sections (default 6000,
# about; 0 for no limit).
# FOLDRENDER_NO_UPDATE_NOTE Set to 1 to never say that a newer CLI is out.
#
# Exit codes: 0 ok, 1 failed, 2 bad usage, 3 not signed in or token rejected,
# 4 a limit reached (the free plan's pages, canvas saves, exports a day),
# 5 page too large, 6 network failure, 7 no such page, or not visible to this
# account (read), 8 page expired (read), 9 too large to read as text, 10 an
# edit did not apply, 11 no new answer before --timeout (answers --wait), 12
# the id or address asked for is another page's (publish).
export LC_ALL=C
FR_CLI_VERSION=0.18.12
# The bridge `foldrender bridge` runs (foldrender-bridge.mjs; each released one
# is served at install/bridge/.mjs): the sha256 of the one this
# version was made with. A bridge that does not match is not run.
FR_BRIDGE_SHA256=2c84e0e03270282d731935416436b439f9ebfeab85bf63637ac43ea97887092c
# The desk agent `foldrender desk agent` runs (foldrender-desk-agent.mjs; each
# released one is served at install/desk-agent/.mjs), pinned as
# the bridge is.
FR_DESK_AGENT_SHA256=9a35b12fd3e6b6d042758274068d46dce17f814c66617fb689aaf3c8ce62235d
API="${FOLDRENDER_API:-https://foldrender.dev/artifacts}"
API="${API%/}"
MAX_BYTES=5242880
CARD_MAX_BYTES=2097152
CANVAS_MAX_CARDS=24
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/foldrender"
# Saved accounts (0.16.0): accounts/ holds that account's token (mode
# 600), accounts/.slugs its slug map and accounts/.desk the id of
# its desk (desk init writes it, so the desk commands send it without --desk);
# "active" names the default. A command's account is, first to last:
# FOLDRENDER_TOKEN (a token: its account is not looked up, and its slug map is
# CONFIG_DIR/slugs, as before; no desk file: whose desk is the server's),
# --as LOGIN or FOLDRENDER_ACCOUNT (a saved account: an agent pins one, so that
# switching in a terminal does not move it), the default. Before 0.16.0 a
# machine had one, CONFIG_DIR/token with CONFIG_DIR/slugs: it is used as the
# default until a command that names accounts (login, logout, auth, whoami)
# learns whose it is and moves it under its login, its slug map copied
# (CONFIG_DIR/slugs stays for FOLDRENDER_TOKEN runs).
ACCOUNTS_DIR="$CONFIG_DIR/accounts"
ACTIVE_FILE="$CONFIG_DIR/active"
LEGACY_TOKEN="$CONFIG_DIR/token"
SLUG_MAP="$CONFIG_DIR/slugs"
TOKEN_FILE=""
TOKEN=""
ACCOUNT=""
ACCOUNT_FROM=""
ACCOUNT_ERROR=""
AS_LOGIN=""
DESK_FILE=""
TMP="$(mktemp -d "${TMPDIR:-/tmp}/foldrender.XXXXXX")" || exit 1
trap 'rm -rf "$TMP"' EXIT
trap 'exit 130' INT TERM
STATUS=""
ACCEPT="text/plain"
die() {
local code="$1"
shift
printf 'foldrender: %s\n' "$*" >&2
exit "$code"
}
note() {
printf 'foldrender: %s\n' "$*" >&2
}
# need COUNT FLAG: a flag that takes a value must not be the last argument.
need() {
[ "$1" -ge 2 ] || die 2 "$2 needs a value"
}
lower() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}
# A GitHub login, as saved here (lowercase): letters, digits and hyphens.
valid_login() {
case "$1" in ''|-*|*[!a-z0-9-]*) return 1 ;; esac
[ "${#1}" -le 39 ]
}
# The saved accounts' logins, one a line, in order.
saved_logins() {
[ -d "$ACCOUNTS_DIR" ] || return 0
local f b
for f in "$ACCOUNTS_DIR"/*; do
b="${f##*/}"
case "$b" in *.*) continue ;; esac
valid_login "$b" && [ -f "$f" ] && printf '%s\n' "$b"
done
return 0
}
active_login() {
head -n 1 "$ACTIVE_FILE" 2>/dev/null | tr -d ' \t\r\n'
}
# use_account LOGIN FROM: this run's account is the saved LOGIN.
use_account() {
ACCOUNT="$1"
ACCOUNT_FROM="$2"
TOKEN_FILE="$ACCOUNTS_DIR/$1"
SLUG_MAP="$ACCOUNTS_DIR/$1.slugs"
DESK_FILE="$ACCOUNTS_DIR/$1.desk"
TOKEN="$(head -n 1 "$TOKEN_FILE" | tr -d ' \t\r\n')"
}
# The account this run uses (see CONFIG_DIR above), into TOKEN, ACCOUNT,
# ACCOUNT_FROM (FOLDRENDER_TOKEN, --as, FOLDRENDER_ACCOUNT, default, or saved for
# the one from before 0.16.0), TOKEN_FILE and SLUG_MAP. One named that is not
# saved here leaves no token and ACCOUNT_ERROR, which require_token says: a
# command that needs no token (help, version) still runs.
select_account() {
local login="" from=""
TOKEN=""; ACCOUNT=""; ACCOUNT_FROM=""; ACCOUNT_ERROR=""; TOKEN_FILE=""; SLUG_MAP="$CONFIG_DIR/slugs"; DESK_FILE=""
if [ -n "${FOLDRENDER_TOKEN:-}" ]; then
TOKEN="$FOLDRENDER_TOKEN"
ACCOUNT_FROM=FOLDRENDER_TOKEN
return 0
fi
if [ -n "$AS_LOGIN" ]; then
login="$AS_LOGIN"; from=--as
elif [ -n "${FOLDRENDER_ACCOUNT:-}" ]; then
login="$FOLDRENDER_ACCOUNT"; from=FOLDRENDER_ACCOUNT
fi
if [ -n "$from" ]; then
login="$(lower "$login")"
if ! valid_login "$login"; then
ACCOUNT_ERROR="not a GitHub login: $login ($from)"
elif [ ! -f "$ACCOUNTS_DIR/$login" ]; then
ACCOUNT_ERROR="$login is not signed in on this machine ($from). Run: foldrender login (approve it as $login), or see who is: foldrender auth list"
else
use_account "$login" "$from"
fi
return 0
fi
login="$(active_login)"
if valid_login "$login" && [ -f "$ACCOUNTS_DIR/$login" ]; then
use_account "$login" default
elif [ -f "$LEGACY_TOKEN" ]; then
TOKEN="$(head -n 1 "$LEGACY_TOKEN" | tr -d ' \t\r\n')"
TOKEN_FILE="$LEGACY_TOKEN"
ACCOUNT_FROM=saved
else
login="$(saved_logins | head -n 1)"
[ -z "$login" ] || use_account "$login" default
fi
}
require_token() {
[ -z "$ACCOUNT_ERROR" ] || die 3 "$ACCOUNT_ERROR"
[ -n "$TOKEN" ] || die 3 "not signed in. Run: foldrender login"
}
# api METHOD PATH [curl args...]: sets STATUS and leaves the body in $TMP/body
# and the headers in $TMP/headers. A failed connection exits here. HTTP errors
# are reported by the caller.
api() {
api_once "$@" || die 6 "cannot reach $API. Check your network connection and try again. ($(curl_error))"
update_note
}
# api_once METHOD PATH [curl args...]: as api, but a failed connection is curl's
# exit code, for the caller to try again.
api_once() {
local method="$1" path="$2"
shift 2
STATUS="$(curl -sS -X "$method" --connect-timeout 15 --max-time 300 \
-H "Accept: $ACCEPT" -H "FoldRender-API: 2" -D "$TMP/headers" -o "$TMP/body" -w '%{http_code}' "$@" \
"$API$path" 2>"$TMP/err")"
}
curl_error() {
sed 's/^curl: ([0-9]*) //' "$TMP/err" | head -n 1
}
# header_value NAME: the value of the header NAME (lowercase) in the last
# answer.
header_value() {
awk -v h="$1" '
{ sub(/\r$/, "") }
tolower(substr($0, 1, length(h) + 1)) == h ":" { v = substr($0, length(h) + 2); sub(/^[ \t]+/, "", v); sub(/[ \t]+$/, "", v) }
END { printf "%s", v }' "$TMP/headers" 2>/dev/null
}
# newer A B: whether version A is above version B, number by number (0.12.0
# is above 0.9.3, which comparing them as strings gets wrong).
newer() {
awk -v a="$1" -v b="$2" 'BEGIN {
na = split(a, x, "."); nb = split(b, y, ".")
for (i = 1; i <= na || i <= nb; i++) if (x[i] + 0 != y[i] + 0) exit (x[i] + 0 > y[i] + 0) ? 0 : 1
exit 1
}'
}
# Every API answer names the newest CLI in an X-FR-CLI header. When that is
# newer than this one, say so on stderr, at most once a day: update-noted keeps
# the day it was said, and the version. Only the run's first answer is looked
# at, and no request is ever made for it.
update_note() {
local latest today
[ -z "${NOTE_SEEN:-}" ] || return 0
NOTE_SEEN=1
[ "${FOLDRENDER_NO_UPDATE_NOTE:-}" != 1 ] || return 0
latest="$(header_value x-fr-cli)"
case "$latest" in ''|*[!0-9.]*|.*|*.|*..*) return 0 ;; esac
newer "$latest" "$FR_CLI_VERSION" || return 0
today="$(date +%Y-%m-%d)"
[ "$(head -n 1 "$CONFIG_DIR/update-noted" 2>/dev/null | cut -d ' ' -f 1)" != "$today" ] || return 0
# Said only once it is written down, so a home that cannot be written to
# does not get it on every command.
{ mkdir -p "$CONFIG_DIR" && printf '%s %s\n' "$today" "$latest" > "$CONFIG_DIR/update-noted"; } 2>/dev/null || return 0
note "CLI $latest is out (you have $FR_CLI_VERSION); what is new: https://foldrender.dev/install/CHANGES.md; update: curl -fsSL https://foldrender.dev/install.sh | sh"
}
# auth_header: prints a curl -H argument that names a file holding the
# Authorization header, so the token is never in curl's argument list, which
# any user of the machine can read (ps). The file is in this run's private
# temporary directory, written with the shell's builtin printf, so no program
# is started with the token among its arguments, and rewritten each time,
# since login changes the token mid-run.
auth_header() {
( umask 077 && printf 'Authorization: Bearer %s\n' "$TOKEN" > "$TMP/auth" ) || die 1 "cannot write $TMP/auth"
printf '@%s' "$TMP/auth"
}
api_auth() {
require_token
api "$@" -H "$(auth_header)"
}
# The server's message for either "error: text" or {"error":"text"} bodies.
error_text() {
local t
t="$(head -n 1 "$TMP/body" | tr -d '\r')"
case "$t" in
error:*) t="${t#error:}" ;;
*'"error"'*) t="$(printf '%s' "$t" | sed -n 's/.*"error" *: *"\([^"]*\)".*/\1/p')" ;;
esac
t="${t# }"
printf '%s' "${t:-no message}"
}
fail_http() {
local msg
msg="$(error_text)"
case "$STATUS" in
401)
# The server's words, unless they say only what this sentence says.
case "$msg" in *"token was rejected"*|unauthorized|"no message") msg="" ;; esac
die 3 "not signed in, or the token was rejected${msg:+ ($msg)}. Run: foldrender login" ;;
403)
if grep -q quota_exceeded "$TMP/body"; then
die 4 "free plan limit reached: 10 pages, each kept for 30 days. See your pages with: foldrender list"
fi
die 1 "not allowed ($msg)" ;;
404) die 1 "not found ($msg)" ;;
413)
# A body over the cap (a publish or an edit) is a page over 5 MB; any
# other 413 says its own reason.
case "$msg" in
*"body exceeds"*|*"html exceeds"*|"request too large"|"no message") die 5 "the page is larger than 5 MB" ;;
esac
die 5 "too large: $msg" ;;
*) die 1 "the server returned HTTP $STATUS ($msg)" ;;
esac
}
expect_2xx() {
case "$STATUS" in
2??) return 0 ;;
esac
fail_http
}
# A page's name or folder in its address: lowercase letters, digits and
# hyphens, starting with a letter or digit, up to 63.
check_name() {
case "$1" in
''|[!a-z0-9]*|*[!a-z0-9-]*) die 2 "$2 takes lowercase letters, digits and hyphens, starting with a letter or digit: $1" ;;
esac
[ "${#1}" -le 63 ] || die 2 "$2 is at most 63 characters: $1"
}
# A page slug is letters, digits, hyphens and underscores. Checking it here
# keeps a typo like "a/b" from becoming a different API path.
check_slug() {
case "$1" in
''|*[!A-Za-z0-9_-]*) die 2 "not a page slug: $1" ;;
esac
}
# GitHub logins: letters, digits and single hyphens, comma-separated.
check_logins() {
case "$1" in
''|-*|*[!A-Za-z0-9,-]*|,*|*,|*,,*) die 2 "not a comma-separated list of GitHub logins: $1" ;;
esac
}
# json_array a,b: the JSON array ["a","b"]. The API wants arrays for share_add
# and share_remove, not strings. Logins were checked by check_logins first.
json_array() {
printf '['
printf '%s' "$1" | awk -F, '{ for (i = 1; i <= NF; i++) printf "%s\"%s\"", (i > 1 ? "," : ""), $i }'
printf ']'
}
# A slug, or a page URL, to the slug.
slug_from() {
local s="${1%%[?#]*}"
s="${s%/}"
printf '%s' "${s##*/}"
}
# The link token in a page address (?k=...), if any.
token_from() {
case "$1" in
*[?\&]k=*) local k="${1#*[?&]k=}"; printf '%s' "${k%%[]*}" ;;
esac
}
# page_id TARGET: sets PAGE_ID to the id of the page TARGET names, and PAGE_K to
# its link token when TARGET carries one. TARGET is an id, a link by id
# (https://foldrender.dev/artifacts/-/, or an old a.foldrender.dev/), a library link
# (...?open=), a page's address (https://foldrender.dev/artifacts//
# [/]), or the desk app's (https://foldrender.dev/desk, the signed-in
# account's desk), which the API resolves to its id. Not run in a subshell,
# because a die inside $(...) would only end the subshell.
page_id() {
local path
PAGE_ID="" PAGE_K="$(token_from "$1")"
case "$1" in
*[?\&]open=*) PAGE_ID="${1#*open=}"; PAGE_ID="${PAGE_ID%%[]*}" ;;
http://*/artifacts/-/*|https://*/artifacts/-/*) PAGE_ID="$(slug_from "$1")" ;;
http://*/artifacts/*|https://*/artifacts/*)
path="/artifacts/${1#*/artifacts/}"
resolve_address "${path%%[?#]*}" "$1"
;;
# Anything else on the site itself is not a page. foldrender.dev//
# (and /repos/..., /github.com/...) is the site's reader for a GitHub
# repository: say where the repository is, rather than look up a page by
# the repository's name.
http://foldrender.dev/*|https://foldrender.dev/*|http://www.foldrender.dev/*|https://www.foldrender.dev/*)
path="/${1#*://*/}"
path="${path%%[?#]*}"
# The desk app: the signed-in account's own desk.
if [ "$path" = "/desk" ]; then
resolve_address /desk "$1" "no desk for this account at $1 (foldrender desk init makes one)"
return
fi
case "$path" in
/guides/*|/install/*|/assets/*|/fonts/*|/zh/*|/ms/*|/api/*|/i18n/*) path="" ;;
esac
path="$(printf '%s\n' "$path" | sed -nE 's#^/(repos/|github\.com/)?([A-Za-z0-9][A-Za-z0-9-]*)/([A-Za-z0-9._-]+).*#\2/\3#p')"
[ -z "$path" ] || die 2 "$1 is FoldRender's reader for the GitHub repository $path, not a page: read the repository itself (https://github.com/$path) or its DeepWiki (https://deepwiki.com/$path)"
die 2 "$1 is not a page's address (pages are at https://foldrender.dev/artifacts/...)"
;;
*) PAGE_ID="$(slug_from "$1")" ;;
esac
}
# resolve_address PATH TARGET [NOT_FOUND]: sets PAGE_ID to the id of the page at
# PATH (an address under /artifacts, or /desk: the signed-in account's own desk),
# as the API resolves it for this account; TARGET is what the user gave, for the
# messages, and NOT_FOUND what a 404 says instead of the usual words.
resolve_address() {
if [ -n "$TOKEN" ]; then
api GET "/api/resolve?path=$(urlencode "$1")${PAGE_K:+&k=$PAGE_K}" -H "$(auth_header)"
else
api GET "/api/resolve?path=$(urlencode "$1")${PAGE_K:+&k=$PAGE_K}"
fi
case "$STATUS" in
200) PAGE_ID="$(sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p' "$TMP/body" | head -n 1)" ;;
401) die 3 "$2 is not public. Sign in to use it: foldrender login" ;;
404) die 7 "${3:-no page at $2, or it is not visible to this account}" ;;
*) fail_http ;;
esac
[ -n "$PAGE_ID" ] || die 1 "the server did not say which page $2 is"
}
# Percent-encodes every byte except unreserved characters.
urlencode() {
local s="$1" out="" i=0 c
while [ "$i" -lt "${#s}" ]; do
c="${s:$i:1}"
case "$c" in
[A-Za-z0-9._~-]) out="$out$c" ;;
# bash 3.2 sign-extends bytes above 0x7F, so mask down to one byte.
*) out="$out$(printf '%%%02X' $(( $(printf '%d' "'$c") & 255 )))" ;;
esac
i=$((i + 1))
done
printf '%s' "$out"
}
# The absolute path of FILE, with symlinks in its directory resolved.
abs_path() {
local dir
dir="$(cd "$(dirname "$1")" && pwd -P)" || return 1
printf '%s/%s\n' "$dir" "$(basename "$1")"
}
# The page's own : the first one. An inline SVG can carry s of its
# own further down, and a greedy match would pick the last of them.
title_from() {
tr '\n' ' ' < "$1" | grep -o ']*>[^<]*' | head -n 1 \
| sed 's/]*>//; s/<\/title>//; s/^ *//; s/ *$//' \
| sed 's/<//g; s/"/"/g; s/'/'"'"'/g; s/'/'"'"'/g; s/ / /g; s/&/\&/g'
}
# The slug named by , if any.
meta_slug() {
local tag
tag="$(grep -o ']*foldrender-slug[^>]*>' "$1" | head -n 1)"
printf '%s' "$tag" | sed -n 's/.*content="\([^"]*\)".*/\1/p'
}
# warn_if_secrets FILE [LABEL]
warn_if_secrets() {
if grep -Eq '(^|[^A-Za-z0-9])(sk-|ghp_|frat_)[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}|-----BEGIN ' "$1"; then
note "${2:-}warning: this page looks like it contains a secret (an API key, token or private key). Anyone with the link can read it."
fi
}
# The slug map: one "absolute-pathslug" line per published file.
map_lookup() {
[ -f "$SLUG_MAP" ] || return 0
awk -F'\t' -v k="$1" '($1 "") == (k "") { s = $2 } END { if (s != "") print s }' "$SLUG_MAP"
}
# map_without COLUMN VALUE: the map minus the rows whose COLUMN is VALUE.
# COLUMN 1 is the path, COLUMN 2 is the slug.
map_without() {
awk -F'\t' -v c="$1" -v v="$2" '((c == 1 ? $1 : $2) "") != (v "")' "$SLUG_MAP"
}
map_forget() {
[ -f "$SLUG_MAP" ] || return 0
map_without "$1" "$2" > "$SLUG_MAP.tmp" && mv -f "$SLUG_MAP.tmp" "$SLUG_MAP"
}
# A temporary file of this run's own: two publishes at once must not write one.
map_set() {
mkdir -p "${SLUG_MAP%/*}"
{
[ -f "$SLUG_MAP" ] && map_without 1 "$1"
printf '%s\t%s\n' "$1" "$2"
} > "$SLUG_MAP.tmp.$$" && mv -f "$SLUG_MAP.tmp.$$" "$SLUG_MAP"
}
# upload METHOD PATH FILE QUERY: the page, as UPLOAD_TYPE (text/html, or
# text/markdown for a Markdown file, which the server makes the page of), sent
# with this publish's Idempotency-Key (PUB_KEY), tried up to UPLOAD_TRIES
# times. A dropped connection, a 5xx, or a 409 in_progress (another request is
# writing this page) is tried again with the same key, and the server answers a
# try whose first attempt did arrive with the page that attempt made: a retry
# never makes a second page.
UPLOAD_TRIES=3
UPLOAD_TYPE=text/html
upload() {
local try=1 rc wait extra=()
require_token
[ -z "${PUB_KEY:-}" ] || extra=(-H "Idempotency-Key: $PUB_KEY")
while :; do
api_once "$1" "$2${4:+?$4}" -H "$(auth_header)" -H "Content-Type: $UPLOAD_TYPE" ${extra[@]+"${extra[@]}"} --data-binary "@$3"
rc=$?
if [ "$rc" -eq 0 ]; then
update_note
case "$STATUS" in
# The Worker cut off for its CPU limit (Cloudflare's error 1102): trying
# again runs the same conversion into the same limit, so it is not.
503) grep -qiE 'error code: 1102|error 1102|exceeded resource limits' "$TMP/body" && return 0 ;;
500|502|503|504) ;;
409) grep -q in_progress "$TMP/body" || return 0 ;;
*) return 0 ;;
esac
fi
[ "$try" -lt "$UPLOAD_TRIES" ] || break
wait=$((try * 2))
if [ "$rc" -eq 0 ] && [ "$STATUS" = 409 ]; then
wait="$(header_value retry-after)"
case "$wait" in ''|*[!0-9]*) wait=2 ;; esac
[ "$wait" -le 10 ] || wait=10
note "${PUB_LABEL:-}another request is writing this page; asking again in ${wait}s"
elif [ "$rc" -ne 0 ]; then
note "${PUB_LABEL:-}no answer from $API ($(curl_error)); trying again in ${wait}s with the same Idempotency-Key, so the page is made at most once"
else
note "${PUB_LABEL:-}the server returned HTTP $STATUS; trying again in ${wait}s with the same Idempotency-Key, so the page is made at most once"
fi
sleep "$wait"
try=$((try + 1))
done
[ "$rc" -eq 0 ] || die 6 "cannot reach $API after $UPLOAD_TRIES tries ($(curl_error)). Run the same command again: it makes the page at most once."
}
# A new Idempotency-Key: 24 random hex digits.
new_key() {
printf 'cli-%s' "$(od -An -N12 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')"
}
# Sets PAGE_URL, and PAGE_ID, from the body of a successful write: the URL on
# the first line, then `id=` from a server that has page addresses (an
# older one sends the link by id alone, whose last part is the id). Not run in a
# subshell, because a die inside $(...) would only end the subshell.
read_page_url() {
PAGE_URL="$(head -n 1 "$TMP/body" | tr -d '\r')"
case "$PAGE_URL" in
http*) ;;
*) die 1 "the server did not return a page URL ($(error_text))" ;;
esac
PAGE_ID="$(field id)"
[ -n "$PAGE_ID" ] || PAGE_ID="$(slug_from "$PAGE_URL")"
}
open_url() {
[ "${FOLDRENDER_NO_BROWSER:-}" = 1 ] && return 0
case "$(uname -s)" in
Darwin) open "$1" >/dev/null 2>&1 ;;
*) xdg-open "$1" >/dev/null 2>&1 ;;
esac
return 0
}
# store_account LOGIN TOKEN: LOGIN's token, saved (mode 600, in a folder only
# this user reads); one saved before for LOGIN is replaced.
store_account() {
mkdir -p "$ACCOUNTS_DIR" || die 1 "cannot make $ACCOUNTS_DIR"
chmod 700 "$ACCOUNTS_DIR" 2>/dev/null
rm -f "$ACCOUNTS_DIR/$1.tmp"
( umask 077 && printf '%s\n' "$2" > "$ACCOUNTS_DIR/$1.tmp" ) || die 1 "cannot write $ACCOUNTS_DIR"
chmod 600 "$ACCOUNTS_DIR/$1.tmp"
mv -f "$ACCOUNTS_DIR/$1.tmp" "$ACCOUNTS_DIR/$1" || die 1 "cannot write $ACCOUNTS_DIR/$1"
}
set_active() {
mkdir -p "$CONFIG_DIR" || die 1 "cannot make $CONFIG_DIR"
printf '%s\n' "$1" > "$ACTIVE_FILE.tmp" && mv -f "$ACTIVE_FILE.tmp" "$ACTIVE_FILE" || die 1 "cannot write $ACTIVE_FILE"
}
# whose_token TOKEN: asks the server whose TOKEN is (GET /api/me). 0 with its
# login in WHO; 1 when the server refused it (STATUS) or could not be reached
# (STATUS empty).
whose_token() {
local saved="$TOKEN"
WHO=""
TOKEN="$1"
if ! api_once GET /api/me -H "$(auth_header)"; then
TOKEN="$saved"; STATUS=""; return 1
fi
TOKEN="$saved"
case "$STATUS" in 2??) ;; *) return 1 ;; esac
WHO="$(lower "$(field login)")"
valid_login "$WHO"
}
# migrate_legacy: the sign-in saved before 0.16.0 (CONFIG_DIR/token), named
# by the server and moved under its login, with a copy of its slug map; the
# default if there is none. Left as it is when the server cannot be reached;
# removed when the server refuses it (401), as no good. When the account has a
# token saved here already (an older CLI signed in again after the move, or a
# login whose /api/me failed), that one is kept, and the old one is revoked on
# the server before it goes: never a good token nobody here knows of.
migrate_legacy() {
local t a had rc
[ -f "$LEGACY_TOKEN" ] || return 0
t="$(head -n 1 "$LEGACY_TOKEN" | tr -d ' \t\r\n')"
if [ -z "$t" ]; then
rm -f "$LEGACY_TOKEN"
return 0
fi
if ! whose_token "$t"; then
if [ "$STATUS" = 401 ]; then
rm -f "$LEGACY_TOKEN"
# It was this run's account: this run has another now, or none.
[ "$ACCOUNT_FROM" != saved ] || select_account
fi
return 0
fi
had="$(head -n 1 "$ACCOUNTS_DIR/$WHO" 2>/dev/null | tr -d ' \t\r\n')"
if [ -n "$had" ] && [ "$had" != "$t" ]; then
# Revoked, or kept here to try again: never dropped while it is still good.
# A GitHub token cannot be revoked by FoldRender: it is dropped here, said once.
revoke_token "$t"; rc=$?
if [ "$rc" = 2 ]; then
note "the sign-in from before 0.16.0 was a GitHub token, no longer used here: revoke it on GitHub if it is not needed (https://github.com/settings/tokens)"
elif [ "$rc" != 0 ]; then
return 0
fi
rm -f "$LEGACY_TOKEN"
[ "$ACCOUNT_FROM" != saved ] || use_account "$WHO" default
return 0
fi
store_account "$WHO" "$t"
[ ! -f "$CONFIG_DIR/slugs" ] || [ -f "$ACCOUNTS_DIR/$WHO.slugs" ] || cp -p "$CONFIG_DIR/slugs" "$ACCOUNTS_DIR/$WHO.slugs"
a="$(active_login)"
valid_login "$a" && [ -f "$ACCOUNTS_DIR/$a" ] || set_active "$WHO"
rm -f "$LEGACY_TOKEN"
[ "$ACCOUNT_FROM" != saved ] || use_account "$WHO" default
}
warn_env_token() {
[ -z "${FOLDRENDER_TOKEN:-}" ] || note "FOLDRENDER_TOKEN is set, so it is used instead of the saved accounts"
}
# json_field KEY: the string under KEY in a JSON body (no quotes inside), or
# in a text error's "KEY: value" line.
json_field() {
{ sed -n "s/.*\"$1\" *: *\"\([^\"]*\)\".*/\1/p" "$TMP/body"; sed -n "s/^$1: //p" "$TMP/body"; } | head -n 1 | tr -d '\r'
}
# json_str FILE: FILE's bytes as a JSON string literal. A sentinel byte after
# the content keeps a trailing newline (awk would drop it); control bytes other
# than tab, CR and LF become \u00XX. Bytes above 0x7F pass as they are, so UTF-8
# stays UTF-8.
json_str() {
# Byte by byte through printf, not gsub: awks (BSD, gawk, busybox) differ on
# backslashes in a gsub replacement, and agree on string literals.
{ cat "$1"; printf 'X'; } | awk '
function emit(s, c) {
while (match(s, /[\001-\037\\"]/)) {
c = substr(s, RSTART, 1)
printf "%s%s", substr(s, 1, RSTART - 1), esc[c]
s = substr(s, RSTART + 1)
}
printf "%s", s
}
BEGIN {
for (i = 1; i < 32; i++) esc[sprintf("%c", i)] = sprintf("\\u%04x", i)
esc["\t"] = "\\t"; esc["\r"] = "\\r"; esc["\\"] = "\\\\"; esc["\""] = "\\\""
printf "\""
}
NR > 1 { emit(prev); printf "\\n" }
{ prev = $0 }
END { sub(/X$/, "", prev); emit(prev); printf "\"" }'
}
# json_arg VALUE: VALUE as a JSON string literal.
json_arg() {
printf '%s' "$1" > "$TMP/arg"
json_str "$TMP/arg"
}
# json_file FILE: as json_str, for a whole page. json_str's awk copies the rest
# of a line for every character it escapes, which on a minified page (one long
# line, many quotes) takes minutes; sed escapes each line in one pass. Its
# replacements are written so that BSD and GNU sed read them alike.
json_file() {
local i
if [ ! -f "$TMP/json.sed" ]; then
{
printf 's/\\\\/\\\\\\\\/g\ns/"/\\\\"/g\ns/\t/\\\\t/g\ns/\r/\\\\r/g\n'
for i in 1 2 3 4 5 6 7 8 11 12 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31; do
printf 's/%b/\\\\u%04x/g\n' "\\0$(printf '%03o' "$i")" "$i"
done
} > "$TMP/json.sed"
fi
{ cat "$1"; printf 'X'; } | sed -f "$TMP/json.sed" | awk '
BEGIN { printf "\"" }
NR > 1 { printf "%s\\n", prev }
{ prev = $0 }
END { sub(/X$/, "", prev); printf "%s\"", prev }'
}
# json_get MODE KEY [FILE]: a member of the JSON object in FILE (the body by
# default), found by walking the text, so a key inside a nested object or a
# string never matches. MODE raw prints the value as written, str a string
# decoded, len how many items an array or object has, pairs one "key
# value" line for each string member of an object. Fails when KEY is absent.
json_get() {
awk -v mode="$1" -v want="$2" '
function hex(h, i, v) {
h = tolower(h)
for (i = 1; i <= 4; i++) v = v * 16 + index("0123456789abcdef", substr(h, i, 1)) - 1
return v
}
function utf8(cp) {
if (cp < 1) return ""
if (cp < 128) return sprintf("%c", cp)
if (cp < 2048) return sprintf("%c%c", 192 + int(cp / 64), 128 + cp % 64)
if (cp < 65536) return sprintf("%c%c%c", 224 + int(cp / 4096), 128 + int(cp / 64) % 64, 128 + cp % 64)
return sprintf("%c%c%c%c", 240 + int(cp / 262144), 128 + int(cp / 4096) % 64, 128 + int(cp / 64) % 64, 128 + cp % 64)
}
# A string literal decoded. Runs between escapes are copied whole: adding
# one character at a time copies the whole result each time.
function unstr(v, c, n, i, j, out, x, cp, lo) {
n = split(v, c, "")
for (i = 2; i < n; i++) {
for (j = i; j < n && c[j] != "\\"; j++) ;
if (j > i) out = out substr(v, i, j - i)
if (j >= n) break
x = c[j + 1]
i = j + 1
if (x == "n") out = out "\n"
else if (x == "t") out = out "\t"
else if (x == "r") out = out "\r"
else if (x == "b") out = out "\b"
else if (x == "f") out = out "\f"
else if (x == "u") {
cp = hex(substr(v, i + 1, 4)); i += 4
if (cp >= 55296 && cp < 56320 && c[i + 1] == "\\" && c[i + 2] == "u") {
lo = hex(substr(v, i + 3, 4))
if (lo >= 56320 && lo < 57344) { cp = 65536 + (cp - 55296) * 1024 + lo - 56320; i += 6 }
}
if (cp >= 55296 && cp < 57344) cp = 65533
out = out utf8(cp)
} else out = out x
}
return out
}
function trim(t) { sub(/^[ \t\r\n]+/, "", t); sub(/[ \t\r\n]+$/, "", t); return t }
# The members of object T (or the items of array T): K[i] the key decoded
# (an array item its number), V[i] the value as written. How many, or -1
# for anything else. It goes from one bracket, comma, colon or string to
# the next, and takes a string whole in one match: a request holds pages
# of HTML, and splitting those into characters takes seconds and 500 MB.
function parts(t, K, V, d, obj, st, cnt, vs, x, rest, at, i) {
t = trim(t)
x = substr(t, 1, 1)
if (x != "{" && x != "[") return -1
obj = x == "{"
cnt = d = st = at = 0
rest = t
while (match(rest, /[]"{}[,:]/)) {
i = at + RSTART
x = substr(rest, RSTART, 1)
if (x == "\"") {
rest = substr(rest, RSTART)
if (!match(rest, /^"([^"\\]|\\.)*"/)) break
if (d == 1 && obj && st == 0) { K[cnt + 1] = unstr(substr(rest, 1, RLENGTH)); st = 1 }
at = i - 1 + RLENGTH
rest = substr(rest, RLENGTH + 1)
continue
}
at = i
rest = substr(rest, RSTART + 1)
if (x == "{" || x == "[") {
if (++d == 1) { st = obj ? 0 : 2; vs = i + 1 }
} else if (x == ":" && d == 1 && obj) {
st = 2; vs = i + 1
} else if (d == 1 && (x == "," || x == "}" || x == "]")) {
if (st == 2 && trim(substr(t, vs, i - vs)) != "") { V[++cnt] = trim(substr(t, vs, i - vs)); if (!obj) K[cnt] = cnt }
st = obj ? 0 : 2; vs = i + 1
if (x != ",") d--
} else if (x == "}" || x == "]") d--
}
return cnt
}
{ s = s (NR > 1 ? "\n" : "") $0 }
END {
n = parts(s, K, V)
for (i = 1; i <= n; i++) if (K[i] == want) v = V[i]
if (v == "") exit 1
if (mode == "raw") printf "%s", v
else if (mode == "str") { if (substr(v, 1, 1) != "\"") exit 1; printf "%s", unstr(v) }
else if (mode == "len") { m = parts(v, K2, V2); if (m < 0) exit 1; print m }
else if (mode == "pairs") {
m = parts(v, K2, V2)
for (i = 1; i <= m; i++) if (substr(V2[i], 1, 1) == "\"") printf "%s\t%s\n", K2[i], unstr(V2[i])
}
}' "${3:-$TMP/body}"
}
# field KEY: the value of a key=value line in the body.
field() {
sed -n "s/^$1=//p" "$TMP/body" | head -n 1 | tr -d '\r'
}
# login [--token TOKEN]: adds an account (or signs one in again) and makes it
# the default; the accounts saved before stay, and auth switch goes back.
cmd_login() {
local token="" login others
while [ $# -gt 0 ]; do
case "$1" in
--token) need $# "$1"; token="$2"; shift 2 ;;
*) die 2 "unknown option: $1" ;;
esac
done
# The sign-in from before 0.16.0 is kept, under its own login.
migrate_legacy
if [ -n "$token" ]; then
TOKEN="$token"
ACCOUNT_ERROR=""
show_account
login="$(lower "$(field login)")"
valid_login "$login" || die 1 "the server did not say whose this token is"
else
device_login
token="$LOGIN_TOKEN"
login="$LOGIN_AS"
if ! valid_login "$login"; then
whose_token "$token" || die 1 "the server did not say whose the new token is"
login="$WHO"
fi
printf 'Signed in as %s.\n' "$login"
fi
store_account "$login" "$token"
set_active "$login"
printf 'Token saved to %s\n' "$ACCOUNTS_DIR/$login"
others="$(saved_logins | awk -v me="$login" '$0 != me' | paste -sd ' ' -)"
[ -z "$others" ] || note "$login is now the default account; also signed in here: ${others// /, } (foldrender auth switch LOGIN, or --as LOGIN for one command)"
warn_env_token
}
device_login() {
local device_code user_code link interval expires pause waited=0 token login
api POST /api/cli/start -H "Content-Type: application/json" --data '{}'
expect_2xx
device_code="$(field device_code)"
user_code="$(field user_code)"
link="$(field verification_uri_complete)"
[ -n "$link" ] || link="$(field verification_uri)"
interval="$(field interval)"
expires="$(field expires_in)"
case "$device_code" in
''|*[!A-Za-z0-9_-]*) die 1 "the server did not start a sign-in" ;;
esac
pause="${interval:-5}"
expires="${expires:-900}"
printf '\nTo sign in, open this page:\n %s\nand enter this code: %s\n\n' "$link" "$user_code"
printf 'Waiting for approval...\n'
open_url "$link"
while :; do
[ "$waited" -lt "$expires" ] || die 1 "the code expired before it was approved. Run: foldrender login"
sleep "$pause"
waited=$((waited + pause))
api POST /api/cli/poll -H "Content-Type: application/json" --data "{\"device_code\":\"$device_code\"}"
if grep -q '^token=' "$TMP/body"; then
token="$(field token)"
login="$(field login)"
[ -n "$token" ] || die 1 "the server returned no token"
LOGIN_TOKEN="$token"
LOGIN_AS="$(lower "$login")"
return 0
fi
grep -q 'authorization_pending' "$TMP/body" && continue
if grep -q 'slow_down' "$TMP/body"; then
pause=$((pause + 5))
continue
fi
grep -q 'expired_token' "$TMP/body" && die 1 "the code expired before it was approved. Run: foldrender login"
grep -q 'access_denied' "$TMP/body" && die 1 "the sign-in was denied"
die 1 "unexpected answer from the sign-in server ($(error_text))"
done
}
# show_account: the account TOKEN is, its plan and pages, on stdout.
show_account() {
api_auth GET /api/me
expect_2xx
if grep -q '^login=' "$TMP/body"; then
awk -F= '
$1 == "login" { l = $2 }
$1 == "plan" { p = $2 }
$1 == "used" { u = $2 }
$1 == "limit" { m = $2 }
END { printf "signed in as %s (%s plan), %s of %s pages used\n", l, p, u, m }' "$TMP/body"
else
cat "$TMP/body"
fi
}
# whoami: the account this command runs as; stderr says where that comes from
# when it is not simply the only one saved.
cmd_whoami() {
[ "$ACCOUNT_FROM" != saved ] || migrate_legacy
show_account
case "$ACCOUNT_FROM" in
--as|FOLDRENDER_ACCOUNT) note "this is $ACCOUNT, named by $ACCOUNT_FROM; the default here is $(active_login)" ;;
default) [ "$(saved_logins | wc -l | tr -d ' ')" -le 1 ] || note "this is the default account; others are signed in here too (foldrender auth list)" ;;
esac
}
# revoke_token TOKEN: the token is revoked on the server (DELETE
# /api/tokens/current). 0 when it is, or was no good already (401); 2 when it
# is not one of FoldRender's to revoke (a GitHub token signed in with login
# --token: 400); 1 when the server could not be reached or would not.
revoke_token() {
local saved="$TOKEN"
TOKEN="$1"
if ! api_once DELETE /api/tokens/current -H "$(auth_header)"; then
TOKEN="$saved"; STATUS=""; return 1
fi
TOKEN="$saved"
case "$STATUS" in 2??|401) return 0 ;; 400) return 2 ;; esac
return 1
}
# logout [LOGIN | --all]: each account's token revoked on the server, then
# removed here (its slug map stays, for when it signs in again). With neither,
# the account this command would run as (FOLDRENDER_TOKEN's is not saved here,
# and is not touched). The default moves to an account still signed in.
cmd_logout() {
local all="" who="" list="" l t failed="" a next rc
while [ $# -gt 0 ]; do
case "$1" in
--all) all=1; shift ;;
-*) die 2 "unknown option: $1" ;;
*) [ -z "$who" ] || die 2 "logout takes one login (or --all)"; who="$(lower "$1")"; shift ;;
esac
done
[ -z "$all" ] || [ -z "$who" ] || die 2 "logout takes a login or --all, not both"
migrate_legacy
if [ -n "$all" ]; then
list="$(saved_logins)"
elif [ -n "$who" ]; then
valid_login "$who" || die 2 "not a GitHub login: $who"
[ -f "$ACCOUNTS_DIR/$who" ] || die 2 "$who is not signed in on this machine (foldrender auth list)"
list="$who"
elif [ -n "$ACCOUNT_ERROR" ]; then
die 3 "$ACCOUNT_ERROR"
elif [ -n "$ACCOUNT" ]; then
list="$ACCOUNT"
elif [ "$ACCOUNT_FROM" = saved ]; then
# From before 0.16.0, and the server could not say whose: revoked and removed as it is.
if revoke_token "$TOKEN"; then printf 'Signed out: the saved token is revoked and removed.\n'
else printf 'Removed the saved token, but could not revoke it on the server (%s). Revoke it under Account & CLI tokens: %s/account\n' "${STATUS:-$(curl_error)}" "$API"; failed=1; fi
rm -f "$LEGACY_TOKEN"
fi
for l in $list; do
t="$(head -n 1 "$ACCOUNTS_DIR/$l" | tr -d ' \t\r\n')"
rc=0
[ -z "$t" ] || { revoke_token "$t"; rc=$?; }
if [ "$rc" = 0 ]; then
printf 'Signed out of %s: its token is revoked and removed.\n' "$l"
elif [ "$rc" = 2 ]; then
printf 'Signed out of %s here. It was signed in with a GitHub token, which FoldRender cannot revoke: revoke it on GitHub if it is no longer needed (https://github.com/settings/tokens).\n' "$l"
else
printf 'Signed out of %s here, but its token could not be revoked on the server (%s). Revoke it under Account & CLI tokens: %s/account\n' "$l" "${STATUS:-$(curl_error)}" "$API"
failed=1
fi
rm -f "$ACCOUNTS_DIR/$l" "$ACCOUNTS_DIR/$l.desk"
done
a="$(active_login)"
if ! { valid_login "$a" && [ -f "$ACCOUNTS_DIR/$a" ]; }; then
next="$(saved_logins | head -n 1)"
if [ -n "$next" ]; then
set_active "$next"
printf 'The default account is now %s.\n' "$next"
else
rm -f "$ACTIVE_FILE"
fi
fi
[ -n "$list$all" ] || [ "$ACCOUNT_FROM" = saved ] || [ -n "${FOLDRENDER_TOKEN:-}" ] || printf 'No account was signed in here.\n'
[ -z "${FOLDRENDER_TOKEN:-}" ] || note "FOLDRENDER_TOKEN is still set in this shell, and is not signed out by this. Unset it to sign out completely."
[ -z "$failed" ] || exit 6
}
# auth list | auth switch LOGIN: the accounts signed in here, and which is the
# default (the account commands run as when FOLDRENDER_TOKEN, --as and
# FOLDRENDER_ACCOUNT name none).
cmd_auth() {
local sub="${1:-list}" to a l n
[ $# -eq 0 ] || shift
case "$sub" in
list|ls|status)
[ $# -eq 0 ] || die 2 "usage: foldrender auth list"
migrate_legacy
a="$(active_login)"
n=0
for l in $(saved_logins); do
n=$((n + 1))
if [ "$l" = "$a" ]; then printf '* %s (default)\n' "$l"; else printf ' %s\n' "$l"; fi
done
if [ "$ACCOUNT_FROM" = saved ]; then
printf '* a sign-in saved before 0.16.0 (whose, the server could not say just now)\n'
n=$((n + 1))
fi
[ "$n" -gt 0 ] || { printf 'No account is signed in here. Run: foldrender login\n'; [ -z "${FOLDRENDER_TOKEN:-}" ] || note "FOLDRENDER_TOKEN is set: commands run as its account"; exit 3; }
case "$ACCOUNT_FROM" in
FOLDRENDER_TOKEN) note "commands run as FOLDRENDER_TOKEN's account here, not as one of these" ;;
--as|FOLDRENDER_ACCOUNT) note "this command ran as $ACCOUNT ($ACCOUNT_FROM)" ;;
esac
[ -z "$ACCOUNT_ERROR" ] || note "$ACCOUNT_ERROR"
;;
switch)
to="${1:-}"
[ $# -le 1 ] || die 2 "usage: foldrender auth switch LOGIN"
migrate_legacy
a="$(active_login)"
if [ -z "$to" ]; then
# Two accounts: the other one. More: say which.
[ "$(saved_logins | wc -l | tr -d ' ')" = 2 ] || die 2 "which account? foldrender auth switch LOGIN (signed in here: $(saved_logins | paste -sd ' ' - | sed 's/ /, /g'))"
to="$(saved_logins | awk -v me="$a" '$0 != me' | head -n 1)"
fi
to="$(lower "$to")"
valid_login "$to" || die 2 "not a GitHub login: $to"
[ -f "$ACCOUNTS_DIR/$to" ] || die 2 "$to is not signed in on this machine (signed in: $(saved_logins | paste -sd ' ' - | sed 's/ /, /g')). To add it: foldrender login"
set_active "$to"
printf 'The default account is now %s.\n' "$to"
[ -z "${FOLDRENDER_TOKEN:-}" ] || note "FOLDRENDER_TOKEN is set in this shell, and still comes first"
[ -z "${FOLDRENDER_ACCOUNT:-}" ] || note "FOLDRENDER_ACCOUNT=$FOLDRENDER_ACCOUNT is set in this shell, and still comes first"
;;
*) die 2 "usage: foldrender auth list | foldrender auth switch LOGIN" ;;
esac
}
# json_number KEY: the number under KEY in a JSON body.
json_number() {
sed -n "s/.*\"$1\" *: *\([0-9][0-9]*\).*/\1/p" "$TMP/body" | head -n 1
}
# size_text BYTES: a size as the server and the manager write it (a MB is
# 1,048,576 bytes).
size_text() {
awk -v n="$1" 'BEGIN {
if (n < 1024) { printf "%d bytes", n; exit }
if (n < 1048576) { printf "%d KB", int(n / 1024 + 0.5); exit }
s = sprintf("%.1f", n / 1048576); sub(/\.0$/, "", s); printf "%s MB", s
}'
}
# export_get PATH: an export request (a plan or a zip), the heavy kind the
# server allows 10 of a minute: a 429 that asks to wait 2 minutes or less is
# waited out and asked again, up to 5 times. A longer one (a daily limit,
# Retry-After in hours) is not waited for: it is left in STATUS for the caller,
# which says the server's sentence (exit 4). A zip may take a while to come
# down, so up to an hour.
export_get() {
local try=1 wait
while :; do
api_auth GET "$1" --max-time 3600
# A zip (not the plan) is asked as a download link on the account's own
# host (?direct=1): the server answers url=… and the zip is fetched from
# there, with no token (the link is the credential). A server without
# links answers the zip itself, as before.
case "$1" in *plan=1*) ;; *) export_direct ;; esac
[ "$STATUS" = 429 ] || return 0
wait="$(header_value retry-after)"
case "$wait" in ''|*[!0-9]*) wait=60 ;; esac
{ [ "$wait" -le 120 ] && [ "$try" -lt 5 ]; } || return 0
[ "$wait" -ge 1 ] || wait=1
note "too many exports in a minute; asking again in ${wait}s"
sleep "$wait"
try=$((try + 1))
done
}
# export_direct: the last answer is a download link (url=https://…--u.foldrender.dev/__fr/export?t=…):
# the zip from it, as the last answer, fetched with no token (the link is the
# credential). The link goes to curl on stdin (-K -), not on its command line,
# where other users of the machine could read it. Anything but a link of that
# shape (or, from a local server, http://localhost) is not followed: STATUS 000.
export_direct() {
local url
case "$STATUS" in 2??) ;; *) return 0 ;; esac
[ "$(head -c 4 "$TMP/body")" = "url=" ] || return 0
url="$(field url)"
case "$url" in
*[\"\\\ ]*) STATUS=000; return 0 ;;
https://*/__fr/export\?t=*) ;;
http://localhost:*/__fr/export\?t=*|http://127.0.0.1:*/__fr/export\?t=*) case "$API" in http://*) ;; *) STATUS=000; return 0 ;; esac ;;
*) STATUS=000; return 0 ;;
esac
STATUS="$(printf 'url = "%s"\n' "$url" | curl -sS --connect-timeout 15 --max-time 3600 -H "Accept: application/zip" -D "$TMP/headers" -o "$TMP/body" -w '%{http_code}' -K - 2>"$TMP/err")" \
|| die 6 "cannot reach your download link. Check your network connection and try again. ($(curl_error))"
}
# export_zip FILE: the zip in the last answer, written to FILE; 1 when the
# answer is not one, with EXPORT_ERR saying why and EXPORT_CODE the exit code:
# a 413 (the pages grew past one download since they were counted) and a 429
# (a limit not waited for) give the server's own sentence; any other refusal
# leaves EXPORT_ERR empty, for fail_http.
export_zip() {
EXPORT_CODE=1
case "$STATUS" in
2??) ;;
413) EXPORT_ERR="$(error_text) Run foldrender export again."; return 1 ;;
429) EXPORT_ERR="$(error_text)"; EXPORT_CODE=4; return 1 ;;
*) EXPORT_ERR=""; return 1 ;;
esac
[ "$(head -c 2 "$TMP/body")" = "PK" ] || { EXPORT_ERR="the server did not return a zip file"; return 1; }
mkdir -p "$(dirname "$1")"
cp "$TMP/body" "$1.part" || die 1 "could not write $1"
mv -f "$1.part" "$1" || die 1 "could not write $1"
}
# export_plan: the plan in the last answer, as PLAN_N parts, PLAN_FP (what the
# listing was) and per part P_PAGES, P_BYTES, P_AFTER, P_TO ("-" for an open
# end) and P_FP; 1 when it is not a plan.
export_plan() {
local k pages bytes after to fp url i=0
PLAN_N="$(field parts)"
PLAN_FP="$(field fingerprint)"
case "$PLAN_N" in ''|*[!0-9]*|0) return 1 ;; esac
grep '^part=' "$TMP/body" | tr -d '\r' > "$TMP/parts"
[ "$(grep -c . "$TMP/parts")" = "$PLAN_N" ] || return 1
P_PAGES=(); P_BYTES=(); P_AFTER=(); P_TO=(); P_FP=()
while IFS="$(printf '\t')" read -r k pages bytes after to fp url <&3; do
i=$((i + 1))
[ "$k" = "part=$i" ] || return 1
case "$pages/$bytes" in */|/*|*[!0-9/]*) return 1 ;; esac
# Each part is a range of page ids, "-" for an open end.
case "$after/$to" in /*|*/|*[!a-z0-9/-]*) return 1 ;; esac
P_PAGES+=("$pages"); P_BYTES+=("$bytes"); P_AFTER+=("$after"); P_TO+=("$to"); P_FP+=("$fp")
done 3< "$TMP/parts"
}
# export_abort CODE MESSAGE: an export that cannot finish leaves nothing behind:
# the parts it saved are removed (EXPORT_FILES), then it exits. With no
# message, the last answer says why (fail_http).
export_abort() {
if [ "${#EXPORT_FILES[@]}" -gt 0 ]; then
rm -f "${EXPORT_FILES[@]}"
note "removed the ${#EXPORT_FILES[@]} part(s) saved before"
fi
[ -z "$2" ] || die "$1" "$2"
fail_http
}
# export [FILE.zip]: every page as .html and a manifest in one zip, with
# what was written on and kept of each — .annotations.json, .panel.md,
# .answers.json and .versions/.html beside .versions.json. Pages that add
# up to more than one download holds (64 MB), in parts FILE-1-of-K.zip ...,
# each with the manifest of its own pages. The default name is
# foldrender-artifacts--.zip. Refuses to overwrite. Prints each
# path it saved, one a line, once the set is whole.
#
# Pages can change while the parts are saved. Each zip says what the whole
# listing was when it was made (X-FR-Export-Plan) and what its own pages were
# (X-FR-Export-Part). When the whole is no longer the plan's, the plan is asked
# for again with the same cuts: with the same ranges, each part saved whose
# pages are no longer those it saved is saved again; with other ranges (a part
# grew past the limit), the parts saved are removed and saved again in the new
# parts. After 3 such rounds it stops and removes what it saved. It never ends
# with a set that misses a page the account had when its last part was saved.
cmd_export() {
[ $# -le 1 ] || die 2 "usage: foldrender export [FILE.zip]"
local target="${1:-}" login="" i k q name mplan cuts same rounds=0 start=1
local saved=() old_after=() old_to=()
EXPORT_FILES=()
require_token
if [ -z "$target" ]; then
api_auth GET /api/me
expect_2xx
login="$(field login)"
[ -n "$login" ] || die 1 "could not read the account login"
target="foldrender-artifacts-$login-$(date +%Y-%m-%d).zip"
fi
[ ! -e "$target" ] || die 1 "$target already exists; choose another name"
# The plan: the parts the pages are downloaded in (GET /api/me/export?plan=1).
export_get "/api/me/export?plan=1"
[ "$STATUS" != 429 ] || die 4 "$(error_text)"
expect_2xx
if [ "$(head -c 2 "$TMP/body")" = "PK" ]; then
# A server from before parts answers the plan with the whole zip.
export_zip "$target" || die 1 "$EXPORT_ERR"
printf '%s\n' "$target"
return 0
fi
export_plan || die 1 "the server did not return an export plan"
if [ "$PLAN_N" = 1 ]; then
export_get "/api/me/export?direct=1"
export_zip "$target" || { [ -z "$EXPORT_ERR" ] || die "$EXPORT_CODE" "$EXPORT_ERR"; fail_http; }
printf '%s\n' "$target"
return 0
fi
while :; do
if [ -n "$start" ]; then
i=1
while [ "$i" -le "$PLAN_N" ]; do
name="${target%.zip}-$i-of-$PLAN_N.zip"
[ ! -e "$name" ] || export_abort 1 "$name already exists; choose another name"
i=$((i + 1))
done
note "your pages add up to $(size_text "$(field bytes)"), more than one download holds ($(size_text "$(field limit)")): saving them in $PLAN_N parts"
start=""
fi
mplan=""
i=0
while [ "$i" -lt "$PLAN_N" ]; do
k=$((i + 1))
if [ -z "${saved[$i]:-}" ]; then
name="${target%.zip}-$k-of-$PLAN_N.zip"
q="part=$k&of=$PLAN_N"
[ "${P_AFTER[$i]}" = - ] || q="$q&after=${P_AFTER[$i]}"
[ "${P_TO[$i]}" = - ] || q="$q&to=${P_TO[$i]}"
export_get "/api/me/export?$q&direct=1"
export_zip "$name" || export_abort "$EXPORT_CODE" "$EXPORT_ERR"
case " ${EXPORT_FILES[*]-} " in *" $name "*) ;; *) EXPORT_FILES+=("$name") ;; esac
saved[$i]="$(header_value x-fr-export-part)"
[ -n "${saved[$i]}" ] || saved[$i]="${P_FP[$i]}"
note "part $k of $PLAN_N: ${P_PAGES[$i]} pages, $(size_text "${P_BYTES[$i]}")"
mplan="$(header_value x-fr-export-plan)"
# The listing is no longer the plan's: see below which parts it moved.
[ -z "$mplan" ] || [ "$mplan" = "$PLAN_FP" ] || break
mplan=""
fi
i=$((i + 1))
done
[ -n "$mplan" ] || break
# The pages changed while the parts were saved: the plan again, its cuts kept.
rounds=$((rounds + 1))
[ "$rounds" -le 3 ] || export_abort 1 "your pages kept changing while the parts were being saved. Run foldrender export again."
old_after=("${P_AFTER[@]}"); old_to=("${P_TO[@]}")
cuts="$(printf '%s\n' "${P_TO[@]}" | grep -v '^-$' | paste -sd , -)"
export_get "/api/me/export?plan=1${cuts:+&cuts=$cuts}"
[ "$STATUS" != 429 ] || export_abort 4 "$(error_text)"
case "$STATUS" in 2??) ;; *) export_abort ;; esac
export_plan || export_abort 1 "the server did not return an export plan"
same=1
[ "${#old_to[@]}" = "$PLAN_N" ] || same=""
i=0
while [ -n "$same" ] && [ "$i" -lt "$PLAN_N" ]; do
[ "${old_after[$i]}" = "${P_AFTER[$i]}" ] && [ "${old_to[$i]}" = "${P_TO[$i]}" ] || same=""
i=$((i + 1))
done
if [ -n "$same" ]; then
i=0
while [ "$i" -lt "$PLAN_N" ]; do
if [ -n "${saved[$i]:-}" ] && [ "${saved[$i]}" != "${P_FP[$i]}" ]; then
note "part $((i + 1)) changed while the parts were being saved; saving it again"
saved[$i]=""
fi
i=$((i + 1))
done
else
[ "${#EXPORT_FILES[@]}" -eq 0 ] || rm -f "${EXPORT_FILES[@]}"
EXPORT_FILES=(); saved=()
note "your pages changed while the parts were being saved; saving them again, in $PLAN_N parts"
start=1
fi
done
printf '%s\n' "${EXPORT_FILES[@]}"
}
# account delete [--confirm LOGIN]: deletes the account with its pages and API
# tokens, then the saved token and slug map on this machine. Without a terminal
# it needs --confirm LOGIN; at a terminal the user types the login.
cmd_account() {
[ "${1:-}" = delete ] || die 2 "usage: foldrender account delete [--confirm LOGIN]"
shift
local confirm="" typed="" login="" pages="" tokens=""
while [ $# -gt 0 ]; do
case "$1" in
--confirm) need $# "$1"; confirm="$2"; shift 2 ;;
*) die 2 "unknown option: $1" ;;
esac
done
if [ -n "$confirm" ]; then
case "$confirm" in
''|*[!A-Za-z0-9-]*) die 2 "not a GitHub login: $confirm" ;;
esac
else
[ -t 0 ] || die 2 "refusing to delete the account without a terminal. Re-run with --confirm if you mean it."
fi
require_token
api_auth GET /api/me
expect_2xx
login="$(field login)"
pages="$(field used)"
[ -n "$login" ] || die 1 "could not read the account login"
printf 'Account %s has %s page(s).\nDeleting it removes every page, every API token and the account. It cannot be undone.\nTo keep a copy of your pages first, run: foldrender export\n' "$login" "${pages:-?}" >&2
if [ -z "$confirm" ]; then
printf 'Type %s to confirm: ' "$login" >&2
read -r typed || die 1 "no answer; nothing was deleted"
confirm="$typed"
fi
confirm="$(printf '%s' "$confirm" | tr -d ' \r\t' | tr '[:upper:]' '[:lower:]')"
[ "$confirm" = "$login" ] || die 1 "the login given does not match $login; nothing was deleted"
api_auth DELETE "/api/me?confirm=$login"
if [ "$STATUS" = 403 ]; then
die 1 "this account cannot be deleted from here: it is the operator's account, or the token is an admin token ($(error_text)). Nothing was deleted."
fi
expect_2xx
pages="$(json_number pages)"
tokens="$(json_number tokens)"
# Its sign-in here, and its slug map; the default moves to another account.
[ -z "$TOKEN_FILE" ] || rm -f "$TOKEN_FILE"
rm -f "$ACCOUNTS_DIR/$login" "$ACCOUNTS_DIR/$login.slugs" "$ACCOUNTS_DIR/$login.desk" "$SLUG_MAP" "$SLUG_MAP.tmp"
if [ "$(active_login)" = "$login" ]; then
rm -f "$ACTIVE_FILE"
[ -z "$(saved_logins | head -n 1)" ] || set_active "$(saved_logins | head -n 1)"
fi
printf 'Deleted the account %s: %s page(s) and %s API token(s).\n' "$login" "${pages:-?}" "${tokens:-?}"
printf 'Removed the saved token and the slug map from this machine.\n'
[ -z "${FOLDRENDER_TOKEN:-}" ] || note "FOLDRENDER_TOKEN is still set in this shell. Unset it to sign out completely."
}
# publish FILE... [options]: each file to its page, one address a line on
# stdout, in order; on stderr one line a page saying what the publish changed.
cmd_publish() {
local files=() title="" slug="" vis="" share="" name="" folder="" folder_set="" as_json="" key="" allow_dup="" message="" f n=0
while [ $# -gt 0 ]; do
case "$1" in
-m|--message) need $# "$1"; message="$2"; shift 2 ;;
--title) need $# "$1"; title="$2"; shift 2 ;;
--slug|--id) need $# "$1"; slug="$2"; shift 2 ;;
--idempotency-key) need $# "$1"; key="$2"; shift 2 ;;
--allow-duplicate) allow_dup=1; shift ;;
--name) need $# "$1"; name="$2"; shift 2 ;;
--folder) need $# "$1"; folder="$2"; folder_set=1; shift 2 ;;
--share) need $# "$1"; share="${2// /}"; shift 2 ;;
--private) vis=private; shift ;;
--link) vis=link; shift ;;
--public) vis=public; shift ;;
--json) as_json=1; shift ;;
-) files+=("-"); shift ;;
-*) die 2 "unknown option: $1" ;;
*) files+=("$1"); shift ;;
esac
done
[ ${#files[@]} -gt 0 ] || die 2 "usage: foldrender publish FILE...|- [--title T] [--name N] [--folder F] [--slug ID] [--private|--link|--public|--share a,b] [--idempotency-key K] [--allow-duplicate] [--json]"
case "$key" in
'') ;;
*[!A-Za-z0-9._:-]*) die 2 "--idempotency-key takes 8 to 128 letters, digits and . _ : -" ;;
*) [ "${#key}" -ge 8 ] && [ "${#key}" -le 120 ] || die 2 "--idempotency-key takes 8 to 120 letters, digits and . _ : -" ;;
esac
if [ ${#files[@]} -gt 1 ]; then
[ -z "$title$slug$name" ] || die 2 "--title, --name and --slug name one page; publish one file with them"
for f in "${files[@]}"; do
[ "$f" != "-" ] || die 2 "publish - takes the page from stdin, so it goes alone"
done
fi
[ -z "$name" ] || check_name "$name" --name
[ -z "$folder" ] || check_name "$folder" --folder
if [ -n "$share" ]; then
[ -z "$vis" ] || die 2 "--share makes the page shared, so drop --private, --link or --public"
check_logins "$share"
vis=shared
fi
require_token
for f in "${files[@]}"; do
n=$((n + 1))
# One key a page: --idempotency-key K names the publish, and each file of
# it is K.1, K.2, ... Without one, each page gets a new key, which its
# retries (upload) send again.
if [ -n "$key" ]; then
PUB_KEY="$key$([ ${#files[@]} -gt 1 ] && printf '.%s' "$n")"
else
PUB_KEY="$(new_key)"
fi
PUB_LABEL="$([ ${#files[@]} -gt 1 ] && printf '%s: ' "$f")"
publish_one "$f" "$PUB_LABEL"
done
}
# publish_one FILE LABEL: one page, with the options cmd_publish read.
publish_one() {
local file="$1" label="$2" p_slug="$slug" p_vis="$vis" p_title="$title" mode="" rp="" src="" bytes query
if [ "$file" = "-" ]; then
src="$TMP/stdin.html"
cat > "$src"
else
[ -f "$file" ] || die 2 "no such file: $file"
[ -r "$file" ] || die 2 "cannot read: $file"
src="$file"
rp="$(abs_path "$file")" || die 1 "cannot resolve the path of $file"
fi
# A Markdown file (by its name) goes as Markdown: the server makes the page
# of it, as the iOS app does, and titles it from its front matter's title:
# or its first # heading.
UPLOAD_TYPE=text/html
case "$(printf '%s' "$file" | tr 'A-Z' 'a-z')" in
*.md|*.markdown|*.mdown|*.mkd) UPLOAD_TYPE=text/markdown ;;
esac
bytes="$(wc -c < "$src" | tr -d ' ')"
[ "$bytes" -gt 0 ] || die 2 "the page is empty: $file"
[ "$bytes" -le "$MAX_BYTES" ] || die 5 "$file is $bytes bytes; the limit is 5 MB"
warn_if_secrets "$src"
# Which page is this? --slug, then a foldrender-slug meta tag, then the
# slug this file was published under before (not with --allow-duplicate,
# which asks for a new page).
if [ -n "$p_slug" ]; then
page_id "$p_slug"
p_slug="$PAGE_ID"
mode=explicit
else
p_slug="$(meta_slug "$src")"
if [ -n "$p_slug" ]; then
mode=meta
elif [ -n "$rp" ] && [ -z "$allow_dup" ]; then
p_slug="$(map_lookup "$rp")"
[ -z "$p_slug" ] || mode=map
fi
fi
[ -z "$p_slug" ] || check_slug "$p_slug"
[ -n "$p_title" ] || [ "$UPLOAD_TYPE" = text/markdown ] || p_title="$(title_from "$src")"
# A new page is private unless told otherwise. A re-publish sends only the
# flags it was given, so the page keeps the visibility and sharing it has.
# A slug alone does not say which of the two this is: the server makes a new
# page for a PUT to a slug nobody has, and gives it the server's default. So
# with no flag, ask first whether the page is one of yours (its version list
# answers 404 otherwise).
if [ -n "$p_slug" ] && [ -z "$p_vis" ]; then
api_auth GET "/api/artifacts/$p_slug/versions"
case "$STATUS" in
200) ;;
404)
p_vis=private
if [ "$mode" = map ]; then
note "${label}the page $p_slug was deleted, so this publish made a new one"
map_forget 1 "$rp"
p_slug=""
fi
;;
*) fail_http ;;
esac
fi
if [ -z "$p_slug" ] && [ -z "$p_vis" ]; then
p_vis=private
fi
query=""
[ -z "$p_title" ] || query="title=$(urlencode "$p_title")"
[ -z "$p_vis" ] || query="${query:+$query&}visibility=$p_vis"
[ -z "$share" ] || query="${query:+$query&}share=$share"
[ -z "$name" ] || query="${query:+$query&}name=$name"
[ -z "$folder_set" ] || query="${query:+$query&}folder=$folder"
[ -z "$allow_dup" ] || query="${query:+$query&}allow_duplicate=1"
[ -z "$message" ] || query="${query:+$query&}m=$(urlencode "$message")"
[ -z "$as_json" ] || ACCEPT="application/json"
if [ -n "$p_slug" ]; then
upload PUT "/api/artifacts/$p_slug" "$src" "$query"
else
upload POST /api/artifacts "$src" "$query"
fi
if [ "$STATUS" = 404 ] && [ "$mode" = map ]; then
note "${label}the page $p_slug was deleted, so this publish made a new one"
map_forget 1 "$rp"
upload POST /api/artifacts "$src" "$query"
fi
# A PUT to an id nobody has makes that page, so a 404 says the id is another
# account's (ids are one namespace for all accounts).
if [ "$STATUS" = 404 ] && [ "$mode" = explicit ]; then
die 12 "${label}not published: the id $p_slug is a page of another account (ids are shared by every account). Choose another --slug."
fi
if [ "$STATUS" = 404 ] && [ "$mode" = meta ]; then
die 12 "${label}not published: the id $p_slug (from the foldrender-slug meta tag) is a page of another account. Change or remove the tag."
fi
if [ "$STATUS" = 409 ] && [ "$(error_text)" = canvas ]; then
die 1 "$p_slug is a canvas, not a page: change it with foldrender canvas edit $p_slug (see: foldrender help canvas)"
fi
if [ "$STATUS" = 409 ] && grep -q in_progress "$TMP/body"; then
die 1 "${label}not published yet: another request is still writing this page. Run the same command again in a few seconds; it answers with that page rather than making another."
fi
if [ "$STATUS" = 409 ] && grep -q 'held_id' "$TMP/body"; then
held_page
die 12 "${label}not published: $HELD_URL is already your page $HELD_ID. To update that page: foldrender publish $(hint_file "$file") --slug $HELD_ID. To make a new page, choose another ${name:+--name}${name:---slug}."
fi
if [ "$STATUS" = 409 ]; then
die 12 "${label}not published: another of your pages is already at that address (${name:+name $name}${folder:+, folder $folder}). Choose another with --name or --folder"
fi
if [ "$STATUS" = 503 ] && grep -qiE 'error code: 1102|error 1102|exceeded resource limits' "$TMP/body"; then
[ "$UPLOAD_TYPE" = text/markdown ] && die 1 "${label}not published: the server ran out of time converting this Markdown file (some shapes, such as a long line of mixed * and _, convert slowly); publish it as an HTML page"
die 1 "${label}not published: the server ran out of time on this page; try again in a while"
fi
if [ "$STATUS" = 422 ] && grep -q idempotency_key_reused "$TMP/body"; then
die 2 "${label}not published: the Idempotency-Key $PUB_KEY was used within the last day for a publish of other content. Use a new key for a new page, or leave --idempotency-key out."
fi
expect_2xx
if [ -n "$as_json" ]; then
ACCEPT="text/plain"
PAGE_ID="$(json_field id)"
PAGE_URL="$(json_field url)"
[ -n "$PAGE_ID" ] || die 1 "the server did not say which page it wrote ($(error_text))"
cat "$TMP/body"
printf '\n'
else
read_page_url
fi
if [ -n "$rp" ]; then
map_set "$rp" "$PAGE_ID"
fi
# A server from before names were kept as asked gives a taken name -2.
if [ -n "$name" ] && [ -z "$as_json" ] && [ "$(field action)" != exists ]; then
case "$(slug_from "$PAGE_URL")" in
"$name"|"$PAGE_ID") ;;
*) note "${label}the name $name is taken by another of your pages, so this one is at $(slug_from "$PAGE_URL"). Choose another with --name" ;;
esac
fi
[ -n "$as_json" ] && return 0
publish_report "$label" "$file" "$p_title"
}
# held_page: HELD_ID and HELD_URL, the page that has the address a write was
# refused (held_id and held_url of a 409 path_taken, as JSON or as text).
held_page() {
HELD_ID="$(json_field held_id)"
HELD_URL="$(json_field held_url)"
}
# The file as a command to update the page would name it.
hint_file() {
case "$1" in
-|'') printf 'FILE' ;;
*[!A-Za-z0-9._/+-]*) printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" ;;
*) printf '%s' "$1" ;;
esac
}
# publish_report LABEL FILE TITLE: after a write, its address on stdout, and on
# stderr first one line saying what happened (from the answer's action=; a
# server from before actions says it through rev= and changed=), then what the
# agent should know before making another page (pages of the account with the
# same content, or the same title), then the next commands.
publish_report() {
local label="$1" f action vis rev id_name dups titles reason req na
f="$(hint_file "$2")"
action="$(field action)"
vis="$(field visibility)"
rev="$(field rev)"
if [ -z "$action" ] && [ -n "$rev" ]; then
if [ "$(field changed)" = 0 ]; then action=unchanged
elif [ "$rev" = 1 ]; then action=created
else action=updated
fi
fi
case "$action" in
created) note "${label}created a new page, id $PAGE_ID${vis:+, $vis}: $PAGE_URL" ;;
updated) note "${label}updated page $PAGE_ID in place, rev $rev: +$(field added) -$(field removed) lines: $PAGE_URL" ;;
unchanged) note "${label}page $PAGE_ID already had this content; nothing changed (rev $rev): $PAGE_URL" ;;
exists)
reason="$(field reason)"
req="$(field requested_id)"
case "$reason" in
idempotency_key) reason="an earlier try of this publish made it (same Idempotency-Key)" ;;
*) reason="you published the same content within the last day" ;;
esac
note "${label}no new page made: this content is already your page $PAGE_ID ($reason)${req:+, so the id $req was not created}: $PAGE_URL" ;;
esac
printf '%s\n' "$PAGE_URL"
# A page whose id is not the last part of its address (one from before ids
# were addresses, or whose name was taken as an id elsewhere): both work.
id_name="$(slug_from "$PAGE_URL")"
[ -z "$action" ] || [ "$id_name" = "$PAGE_ID" ] ||
note "${label}its id ($PAGE_ID) is not the last part of its address ($id_name); commands take the id or the whole address"
na="$(field not_applied)"
[ "$action" != exists ] || [ -z "$na" ] ||
note "${label}that page was left as it is, so what you asked for its $(printf '%s' "$na" | sed 's/,/, /g') was not applied; to change them, publish to it: foldrender publish $f --slug $PAGE_ID with those options"
dups="$(field duplicates)"
if [ -n "$dups" ] && [ "$dups" != 0 ]; then
note "${label}$dups other page(s) of yours have exactly this content: $(sed -n 's/^duplicate=//p' "$TMP/body" | tr '\t' ' ' | paste -sd ';' - | sed 's/;/; /g'). List them all: foldrender ls --duplicates"
fi
titles="$(field same_titles)"
if [ -n "$titles" ] && [ "$titles" != 0 ]; then
note "${label}you have $titles other page(s) titled \"${3:-this}\": $(sed -n 's/^same_title=//p' "$TMP/body" | tr '\t' ' ' | paste -sd ';' - | sed 's/;/; /g'). To update one of them instead: foldrender publish $f --slug ID"
fi
[ -n "$action" ] || return 0
note "${label}next: change it with foldrender publish $f --slug $PAGE_ID (or foldrender edit $PAGE_ID), read it with foldrender read $PAGE_ID, delete it with foldrender rm $PAGE_ID$([ "$action" = exists ] && printf '; to make a second page with this content anyway, add --allow-duplicate')"
}
# edit PAGE --old TEXT --new TEXT [...]: exact replacements in the page's HTML,
# made by the server, so only the changes are sent. Each --old must be in the
# page exactly once, as the edits before it left it. --edits FILE (or -) reads
# them as JSON: an array of {"old": ..., "new": ...}, or the request's object.
# --base REV refuses to edit a page that has moved on from that revision.
cmd_edit() {
local target="" base="" title="" efile="" n=0 have_old="" as_json="" c message=""
: > "$TMP/edits.json"
while [ $# -gt 0 ]; do
case "$1" in
--old)
need $# "$1"
[ -z "$have_old" ] || die 2 "--old $((n + 1)) has no --new after it"
printf '%s' "$2" > "$TMP/old"
have_old=1
shift 2 ;;
--new)
need $# "$1"
[ -n "$have_old" ] || die 2 "--new needs an --old before it"
printf '%s' "$2" > "$TMP/new"
[ "$n" -eq 0 ] || printf ',' >> "$TMP/edits.json"
{ printf '{"old":'; json_str "$TMP/old"; printf ',"new":'; json_str "$TMP/new"; printf '}'; } >> "$TMP/edits.json"
n=$((n + 1))
have_old=""
shift 2 ;;
--edits) need $# "$1"; efile="$2"; shift 2 ;;
--base)
need $# "$1"
case "$2" in ''|*[!0-9]*) die 2 "--base takes a revision number (see: foldrender versions PAGE)" ;; esac
base="$2"; shift 2 ;;
--title) need $# "$1"; title="$2"; shift 2 ;;
-m|--message) need $# "$1"; message="$2"; shift 2 ;;
--json) as_json=1; shift ;;
-*) die 2 "unknown option: $1" ;;
*) [ -z "$target" ] || die 2 "edit takes one page"; target="$1"; shift ;;
esac
done
[ -z "$have_old" ] || die 2 "the last --old has no --new after it"
[ -n "$target" ] || die 2 "usage: foldrender edit PAGE --old TEXT --new TEXT [--old ... --new ...] [--base REV] | --edits FILE"
[ "$n" -gt 0 ] || [ -n "$efile" ] || die 2 "nothing to change: give --old TEXT --new TEXT, or --edits FILE"
[ "$n" -eq 0 ] || [ -z "$efile" ] || die 2 "--edits and --old/--new do not go together"
require_token
page_id "$target"
check_slug "$PAGE_ID"
if [ -n "$efile" ]; then
if [ "$efile" = - ]; then
cat > "$TMP/efile"
else
[ -f "$efile" ] || die 2 "no such file: $efile"
cp "$efile" "$TMP/efile"
fi
c="$(tr -d ' \t\r\n' < "$TMP/efile" | head -c 1)"
case "$c" in
'[') { printf '{"edits":'; cat "$TMP/efile"; } > "$TMP/edit.json" ;;
'{') [ -z "$base$title" ] || die 2 "the --edits object carries its own base and title; drop --base and --title"
cp "$TMP/efile" "$TMP/edit.json" ;;
*) die 2 "--edits takes JSON: an array of {\"old\": ..., \"new\": ...}, or an object with edits" ;;
esac
else
{ printf '{"edits":['; cat "$TMP/edits.json"; printf ']'; } > "$TMP/edit.json"
fi
if [ "$c" != '{' ]; then
[ -z "$base" ] || printf ',"base":%s' "$base" >> "$TMP/edit.json"
if [ -n "$title" ]; then
printf '%s' "$title" > "$TMP/title"
{ printf ',"title":'; json_str "$TMP/title"; } >> "$TMP/edit.json"
fi
if [ -n "$message" ]; then
printf '%s' "$message" > "$TMP/message"
{ printf ',"message":'; json_str "$TMP/message"; } >> "$TMP/edit.json"
fi
printf '}' >> "$TMP/edit.json"
fi
[ -z "$as_json" ] || ACCEPT="application/json"
api_auth POST "/api/artifacts/$PAGE_ID/edit" -H "Content-Type: application/json" --data-binary "@$TMP/edit.json"
case "$STATUS" in
404) die 7 "no page named $PAGE_ID among yours" ;;
409)
[ -z "$(json_field message)" ] || die 10 "$(json_field message)"
die 10 "the edit did not apply ($(error_text))" ;;
esac
expect_2xx
if [ -n "$as_json" ]; then
cat "$TMP/body"
printf '\n'
return 0
fi
read_page_url
publish_report "" "" ""
}
# log PAGE: the page's history, newest first — a line a revision: when, who
# and from which client, the lines it added and removed, its message, and
# gone:trimmed|deleted once its body is gone (a page keeps the bodies of its
# newest 10 versions; the log keeps them all). `versions` is its old name.
cmd_log() {
local target="" as_json="" before="" query=""
while [ $# -gt 0 ]; do
case "$1" in
--json) as_json=1; shift ;;
--before)
[ $# -ge 2 ] || die 2 "--before takes a revision number"
before="$2"; shift 2 ;;
-*) die 2 "unknown option: $1" ;;
*) [ -z "$target" ] || die 2 "log takes one page"; target="$1"; shift ;;
esac
done
[ -n "$target" ] || die 2 "usage: foldrender log PAGE [--before REV] [--json]"
require_token
page_id "$target"
check_slug "$PAGE_ID"
case "$before" in "") : ;; *[!0-9]*) die 2 "--before takes a revision number, not: $before" ;; *) query="?before=$(urlencode "$before")" ;; esac
[ -z "$as_json" ] || ACCEPT="application/json"
api_auth GET "/api/artifacts/$PAGE_ID/log$query"
if [ "$STATUS" = 404 ]; then
# Either no such page, or a server from before the log: its kept-versions
# list says which (and is all it can show).
api_auth GET "/api/artifacts/$PAGE_ID/versions"
[ "$STATUS" != 404 ] || die 7 "no page named $PAGE_ID among yours"
expect_2xx
if [ -n "$as_json" ] || [ "$(head -c 1 "$TMP/body")" = "{" ]; then
cat "$TMP/body"
printf '\n'
return 0
fi
awk -F'\t' -v page="$target" '
NR == 1 { sub(/^rev=/, ""); printf "%s is at rev %s\n", page, $0; next }
{ d = $2; sub(/T/, " ", d); printf "%s %s %7.1f KB %s\n", $1, substr(d, 1, 16), $3 / 1024, $4 }
END {
if (NR <= 1) print "no earlier versions"
else printf "put one back: foldrender restore %s VERSION (or foldrender undo %s for the newest)\n", page, page
}' "$TMP/body"
return 0
fi
expect_2xx
if [ -n "$as_json" ] || [ "$(head -c 1 "$TMP/body")" = "{" ]; then
cat "$TMP/body"
printf '\n'
return 0
fi
awk -F'\t' -v page="$target" '
NR == 1 { sub(/^rev=/, ""); printf "%s is at rev %s\n", page, $0; next }
{
d = $2; sub(/T/, " ", d)
gone = ""; vid = ""
for (i = 5; i <= NF; i++) {
if ($i ~ /^gone:/) gone = " [" substr($i, 6) "]"
else if ($i ~ /^v[0-9a-f]{16}$/) vid = " " $i
}
if ($1 == "0") { printf " … %s%s\n", $5, ($2 ? " (" substr($2, 1, 16) ")" : ""); next }
printf "rev %-4s %s %-28s %7s %s%s%s\n", $1, substr(d, 1, 16), $3, $4, $5, gone, vid
}
/^…older revisions:/ { sub(/PAGE/, page); print; next }
END {
if (NR <= 1) print "no history yet"
else printf "put a revision back: foldrender restore %s REV (undo %s for the one before)\n", page, page
}' "$TMP/body"
}
# diff PAGE [A [B]] [--html]: revision A to B as a unified diff, of the text a
# read gives (or of the stored HTML with --html); with no revisions, the one
# before the current against it.
cmd_diff() {
local target="" a="" b="" html="" as_json="" query
while [ $# -gt 0 ]; do
case "$1" in
--html) html=1; shift ;;
--json) as_json=1; shift ;;
-*) die 2 "unknown option: $1" ;;
*) if [ -z "$target" ]; then target="$1"; elif [ -z "$a" ]; then a="$1"; elif [ -z "$b" ]; then b="$1"; else die 2 "diff takes a page and at most two revisions"; fi; shift ;;
esac
done
[ -n "$target" ] || die 2 "usage: foldrender diff PAGE [REV [REV]] [--html] [--json]"
require_token
page_id "$target"
check_slug "$PAGE_ID"
query=""
[ -z "$a" ] || query="a=$a"
[ -z "$b" ] || query="${query:+$query&}b=$b"
[ -z "$html" ] || query="${query:+$query&}html=1"
[ -z "$as_json" ] || ACCEPT="application/json"
api_auth GET "/api/artifacts/$PAGE_ID/diff${query:+?$query}"
[ "$STATUS" != 404 ] || die 7 "no page named $PAGE_ID among yours"
if [ "$STATUS" = 410 ]; then
die 1 "that version's body is gone ($(json_field rev 2>/dev/null || true)$(json_field gone 2>/dev/null | sed 's/^/, /')); its log entry stays (foldrender log $target)"
fi
expect_2xx
if [ -n "$as_json" ] || [ "$(head -c 1 "$TMP/body")" = "{" ]; then
if [ -z "$as_json" ] && grep -q '"too_large":true' "$TMP/body"; then
note "the diff is too large to show; the counts: $(json_field added) added, $(json_field removed) removed"
return 0
fi
cat "$TMP/body"
printf '\n'
return 0
fi
cat "$TMP/body"
}
# restore PAGE REV|VERSION: that revision (by number, or by a kept version's
# id) becomes the page again, as its next commit — the history only grows, so
# a restore can itself be undone.
cmd_restore() {
[ $# -eq 2 ] || die 2 "usage: foldrender restore PAGE REV|VERSION (see: foldrender log PAGE)"
require_token
page_id "$1"
check_slug "$PAGE_ID"
case "$2" in
v[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) set -- "$1" "${2#v}" ;;
esac
case "$2" in
''|*[!0-9a-f]*) die 2 "not a revision or version id: $2 (see: foldrender log $1)" ;;
esac
restore_version "$1" "$2"
}
restore_version() {
# A kept version's id is sixteen hex characters, all digits in about one of
# 1,845; a revision is at most fifteen digits (REV_NUM_RE in the Worker).
local what="rev"
case "$2" in *[!0-9]*|????????????????*) what="version" ;; esac
api_auth POST "/api/artifacts/$PAGE_ID/versions/$2/restore"
[ "$STATUS" != 404 ] || die 7 "no version $2 of $1 (see: foldrender log $1)"
[ "$STATUS" != 409 ] || die 1 "rev $2 is already the page"
[ "$STATUS" != 410 ] || die 1 "the body of $what $2 is gone; its log entry stays (foldrender log $1)"
expect_2xx
read_page_url
printf '%s\n' "$PAGE_URL"
note "restored $what $2${PAGE_ID:+ of $PAGE_ID}$( [ -n "$(field rev)" ] && printf ' as rev %s' "$(field rev)")"
}
# undo PAGE: puts back the revision before the current one. The current body
# is kept in turn, so undo twice is back where it was.
cmd_undo() {
[ $# -eq 1 ] || die 2 "usage: foldrender undo PAGE"
local vid rev
require_token
page_id "$1"
check_slug "$PAGE_ID"
api_auth GET "/api/artifacts/$PAGE_ID/log"
if [ "$STATUS" = 200 ]; then
rev="$(sed -n '1s/^rev=//p' "$TMP/body")"
# The newest revision before the current one whose body is still kept.
vid="$(awk -F'\t' -v cur="$rev" 'NR > 1 && $1 < cur && $0 !~ /gone:/ { print $1; exit }' "$TMP/body")"
case "$vid" in
''|*[!0-9]*) die 1 "$1 has no earlier revision with a body to go back to (see: foldrender log $1)" ;;
esac
restore_version "$1" "$vid"
return 0
fi
# A server from before the log: the newest kept version, as before.
api_auth GET "/api/artifacts/$PAGE_ID/versions"
[ "$STATUS" != 404 ] || die 7 "no page named $PAGE_ID among yours"
expect_2xx
vid="$(sed -n '2p' "$TMP/body" | cut -f1)"
case "$vid" in
''|*[!0-9a-f]*) die 1 "$1 has no earlier version to go back to" ;;
esac
restore_version "$1" "$vid"
}
# share PAGE... [+login ...] [-login ...] [--private|--shared|--link|--public]
# [--reset-link] [--collab|--no-collab] [--copy|--no-copy]: who can open the
# pages, said after each in the words of the site's Share panel (src/access.js).
# People are added and removed as the panel does it (accessChange there): on a
# page open to anyone with the link, or public, the list is kept and who can open
# the page does not change; on a private page the first person added makes it
# shared ("Only people added"), and on a shared page the last one removed makes
# it private again. --shared asks for "Only people added" itself. +login with
# --private is refused: private is the owner alone.
cmd_share() {
local targets=() vis="" adds="" removes="" arg json reset="" as_json="" t collab="" copy="" now visit
for arg in "$@"; do
case "$arg" in
--public) vis=public ;;
--link) vis=link ;;
--private) vis=private ;;
--shared) vis=shared ;;
--reset-link) reset=1 ;;
--collab) collab=true ;;
--no-collab) collab=false ;;
--copy) copy=true ;;
--no-copy) copy=false ;;
--json) as_json=1 ;;
--*) die 2 "unknown option: $arg" ;;
+?*) check_logins "${arg#+}"; adds="${adds:+$adds,}${arg#+}" ;;
-?*) check_logins "${arg#-}"; removes="${removes:+$removes,}${arg#-}" ;;
?*) targets+=("$arg") ;;
*) die 2 "expected a page, +login, -login, --private, --shared, --link, --public, --reset-link, --collab or --copy" ;;
esac
done
[ ${#targets[@]} -gt 0 ] || die 2 "usage: foldrender share PAGE... [+login ...] [-login ...] [--private|--shared|--link|--public] [--reset-link] [--collab|--no-collab] [--copy|--no-copy]"
[ -n "$adds$removes$vis$reset$collab$copy" ] || die 2 "nothing to change: give +login, -login, --private, --shared, --link, --public, --reset-link, --collab or --copy"
if [ -n "$adds" ] && [ "$vis" = private ]; then
die 2 "--private is you alone, so +login would let nobody in. To share with the people you name: foldrender share PAGE +login"
fi
json=""
[ -z "$reset" ] || json="\"reset_link\":true"
[ -z "$adds" ] || json="${json:+$json,}\"share_add\":$(json_array "$adds")"
[ -z "$removes" ] || json="${json:+$json,}\"share_remove\":$(json_array "$removes")"
[ -z "$collab" ] || json="${json:+$json,}\"collab\":$collab"
[ -z "$copy" ] || json="${json:+$json,}\"copy\":$copy"
ACCEPT="application/json"
for t in "${targets[@]}"; do
page_id "$t"
check_slug "$PAGE_ID"
visit="$vis"
# People added with no access asked for: a page open to the link, or public,
# stays so (the server would make it shared with them alone).
if [ -n "$adds" ] && [ -z "$visit" ]; then
api_auth GET "/api/artifacts/$PAGE_ID?format=meta"
[ "$STATUS" != 404 ] || die 7 "no page named $PAGE_ID among yours"
expect_2xx
now="$(json_get str visibility 2>/dev/null)"
case "$now" in link|public) visit="$now" ;; esac
fi
api_auth PATCH "/api/artifacts/$PAGE_ID" -H "Content-Type: application/json" \
--data "{${visit:+\"visibility\":\"$visit\"}${visit:+${json:+,}}$json}"
[ "$STATUS" != 404 ] || die 7 "no page named $PAGE_ID among yours"
expect_2xx
# Shared with nobody (the last person removed, or --shared on an empty
# list): the page is its owner's alone, which the panel calls private.
if [ "$(json_get str visibility)" = shared ] && [ "$(json_get len share)" = 0 ]; then
api_auth PATCH "/api/artifacts/$PAGE_ID" -H "Content-Type: application/json" --data '{"visibility":"private"}'
expect_2xx
fi
if [ -n "$as_json" ]; then
cat "$TMP/body"
printf '\n'
else
json_get str page_url 2>/dev/null || json_get str url
printf '\n'
fi
say_access "$t" "$collab" "$copy"
done
}
# say_access PAGE COLLAB COPY: on stderr, who can open the page just written
# (the body), as the site's Share panel says it, and collaboration and copies
# when they were asked to change.
say_access() {
local who names
case "$(json_get str visibility 2>/dev/null)" in
public) who="Public" ;;
link) who="Anyone with the link" ;;
shared)
names="$(json_get raw share 2>/dev/null | tr -d '[]" ' | sed 's/,/, /g')"
if [ -n "$names" ]; then who="Only people added ($names)"; else who="Only you"; fi ;;
*) who="Only you" ;;
esac
note "$1: who can open it: $who"
case "$(json_get str visibility 2>/dev/null)" in
link|public)
names="$(json_get raw share 2>/dev/null | tr -d '[]" ' | sed 's/,/, /g')"
[ -z "$names" ] || note "$1: people added ($names) are kept for when it is Only people added: foldrender share $1 --shared" ;;
esac
case "$2" in
true) note "$1: collaboration on: readers who sign in can write on it" ;;
false) note "$1: collaboration off: their ink stays, seen by you alone" ;;
esac
case "$3" in
true) note "$1: readers can copy this page" ;;
false) note "$1: copies of this page are off" ;;
esac
}
# list_json_filter WORDS ACCESS FOLDER LIMIT: the answer of GET /api/artifacts
# ({"artifacts":[{id,visibility,title,folder,name,page_url,…},…]}) on stdin,
# filtered as the table's rows are: every word in id+title+page_url (any case),
# access one kind, folder exact, the first LIMIT of what matches (0: all).
# Item text is copied through as written, so fields the filter never reads
# (size, updated, collab, page_url's token) reach stdout untouched.
list_json_filter() {
awk -v words="$1" -v access="$2" -v folder="$3" -v limit="$4" '
function unstr(v, c, n, i, j, out) {
n = split(v, c, "")
for (i = 2; i < n; i++) {
for (j = i; j < n && c[j] != "\\"; j++) ;
if (j > i) out = out substr(v, i, j - i)
if (j >= n) break
if (c[j + 1] == "u") { out = out "?"; i = j + 4 }
else { out = out c[j + 1]; i = j + 1 }
}
return out
}
# member WANT of object T: the value text as written (a string keeps its
# quotes and escapes; callers unstr it), "" when absent.
function member(t, want, m, v) {
if (!match(t, "\"" want "\"[ \t]*:[ \t]*")) return ""
v = substr(t, RSTART + RLENGTH)
if (match(v, /^"([^"\\]|\\.)*"/)) return substr(v, 1, RLENGTH)
if (match(v, /^[^,}\]]*/)) return substr(v, 1, RLENGTH)
return ""
}
{ s = s $0 }
END {
nw = split(tolower(words), w, " ")
sub(/^[\t\r\n ]*[{][\t\r\n ]*"artifacts"[\t ]*:[\t ]*\[/, "", s)
d = 1; vs = 0; n = 0; kept = 0; out = ""
for (i = 1; i <= length(s); i++) {
x = substr(s, i, 1)
if (x == "\"") { match(substr(s, i), /^"([^"\\]|\\.)*"/); i += RLENGTH - 1; continue }
if (x == "{" || x == "[") { if (d == 1 && x == "{") vs = i; d++ }
else if (x == "}" || x == "]") {
d--
if (d == 0) break
if (d == 1 && vs) {
it = substr(s, vs, i - vs + 1); vs = 0
id = unstr(member(it, "id"))
if (id != "") {
ok = 1
hay = tolower(id " " unstr(member(it, "title")) " " unstr(member(it, "page_url")))
for (k = 1; k <= nw; k++) if (index(hay, w[k]) == 0) { ok = 0; break }
if (ok && access != "" && unstr(member(it, "visibility")) != access) ok = 0
if (ok && folder != "" && unstr(member(it, "folder")) != folder) ok = 0
if (ok) { n++; if (limit == 0 || n <= limit) out = out (kept++ ? "," : "") it }
}
}
}
}
printf "{\"artifacts\":[%s]}\n", out
}'
}
# ls [WORDS...]: your pages, newest first, one line each: the newest 20 unless
# --all or -n N. WORDS must all be in the id, title or address (any case);
# --access keeps one kind, --folder one folder; --long adds each address.
cmd_list() {
local words="" limit=20 access="" folder="" long="" as_json="" dups=""
while [ $# -gt 0 ]; do
case "$1" in
--all|-a) limit=0; shift ;;
-n) need $# "$1"; case "$2" in ''|*[!0-9]*) die 2 "-n takes a number" ;; esac; limit="$2"; shift 2 ;;
--access|--vis|--visibility)
need $# "$1"
case "$2" in public|link|private|shared) access="$2" ;; *) die 2 "$1 takes public, link, private or shared" ;; esac
shift 2 ;;
--folder) need $# "$1"; check_name "$2" --folder; folder="$2"; shift 2 ;;
--long|-l) long=1; shift ;;
--duplicates) dups=1; shift ;;
--json) as_json=1; shift ;;
-*) die 2 "unknown option: $1" ;;
*) words="$words $1"; shift ;;
esac
done
[ -z "$dups" ] || { list_duplicates "$as_json"; return; }
if [ -n "$as_json" ]; then
ACCEPT="application/json"
api_auth GET /api/artifacts
expect_2xx
list_json_filter "$words" "$access" "$folder" "$limit" < "$TMP/body"
return 0
fi
api_auth GET /api/artifacts
expect_2xx
if [ ! -s "$TMP/body" ]; then
printf 'No pages yet.\n'
return 0
fi
# The list: id, access, updated (ISO), expires or "-", title, address. The
# table has both names of each page, its id (what commands take, as they take
# the address) and its address. --long is what the table always is now.
: "$long"
awk -F'\t' -v words="$words" -v access="$access" -v folder="$folder" -v limit="$limit" '
BEGIN { nw = split(tolower(words), w, " "); wid = 2; aw = 7 }
{
hay = tolower($1 " " $5 " " $6)
for (i = 1; i <= nw; i++) if (index(hay, w[i]) == 0) next
if (access != "" && $2 != access) next
if (folder != "" && index($6, "/" folder "/") == 0) next
n++
if (limit > 0 && n > limit) next
id[n] = $1; acc[n] = $2; t[n] = $5; addr[n] = ($6 == "" ? "-" : $6)
up[n] = substr($3, 1, 10) " " substr($3, 12, 5)
ex[n] = ($4 != "-" && $4 != "") ? " (expires " substr($4, 1, 10) ")" : ""
if (length($1) > wid) wid = length($1)
if (length(addr[n]) > aw) aw = length(addr[n])
}
END {
shown = (limit > 0 && n > limit) ? limit : n
if (shown == 0) { print (NR ? "No page matches." : "No pages yet."); exit }
printf "%-" wid "s %-7s %-16s %-" aw "s %s\n", "ID", "ACCESS", "UPDATED (UTC)", "ADDRESS", "TITLE"
for (i = 1; i <= shown; i++) {
printf "%-" wid "s %-7s %-16s %-" aw "s %s%s\n", id[i], acc[i], up[i], addr[i], t[i], ex[i]
}
if (n > shown) printf "%d more: foldrender ls --all, or narrow it: foldrender ls WORDS\n", n - shown
}' "$TMP/body"
}
# ls --duplicates [--json]: the account's pages that share their content with
# another of its pages, in groups, the oldest first, and the command that would
# delete the rest of each group. It deletes nothing.
list_duplicates() {
[ -z "$1" ] || ACCEPT="application/json"
api_auth GET "/api/artifacts?duplicates=1"
ACCEPT="text/plain"
[ "$STATUS" != 503 ] || die 1 "this server cannot compare pages ($(error_text))"
expect_2xx
if [ -n "$1" ]; then
cat "$TMP/body"
printf '\n'
return 0
fi
# A line a page: hash, id, access, created, updated, address, title; then
# "unhashedN".
awk -F'\t' '
BEGIN { wid = 2; aw = 7 }
$1 == "unhashed" { unhashed = $2 + 0; next }
NF >= 7 {
if ($1 != h) { g++; h = $1; hash[g] = $1; n[g] = 0; rest[g] = ""; apart[g] = 0 }
k = ++n[g]
id[g, k] = $2; acc[g, k] = $3; made[g, k] = substr($4, 1, 10) " " substr($4, 12, 5); addr[g, k] = $6; t[g, k] = $7
if (k > 1 && (acc[g, k] != acc[g, 1] || t[g, k] != t[g, 1])) apart[g] = 1
if (k > 1) rest[g] = rest[g] " " $2
if (length($2) > wid) wid = length($2)
if (length($6) > aw) aw = length($6)
}
END {
if (g == 0) print "No duplicates: no two of your pages have the same content."
else printf "%d group(s) of your pages have the same content; in each the first is the oldest. Nothing was deleted. Two pages can share content on purpose (a test page, another access or readers): ask the user before deleting any.\n", g
for (i = 1; i <= g; i++) {
printf "\nsame content (%s), %d pages:\n", substr(hash[i], 1, 12), n[i]
printf " %-" wid "s %-7s %-16s %-" aw "s %s\n", "ID", "ACCESS", "CREATED (UTC)", "ADDRESS", "TITLE"
for (k = 1; k <= n[i]; k++) printf " %-" wid "s %-7s %-16s %-" aw "s %s\n", id[i, k], acc[i, k], made[i, k], addr[i, k], t[i, k]
if (apart[i]) printf " their access or titles differ, so they may be kept apart on purpose; if they are copies, keep the oldest: foldrender rm%s\n", rest[i]
else printf " if they are copies, keep the oldest: foldrender rm%s\n", rest[i]
}
if (unhashed > 0) printf "\n%d page(s) published before content fingerprints were not compared.\n", unhashed
}' "$TMP/body"
}
# rm PAGE...: one line a page on stdout, saying which page went by both its
# names (id and address) and its title; --json the API's answer, one a line.
cmd_rm() {
local t failed=0 as_json="" targets=() url title rev="" vid=""
while [ $# -gt 0 ]; do
case "$1" in
--json) as_json=1; shift ;;
--rev) need $# "$1"; case "$2" in ''|*[!0-9]*) die 2 "--rev takes a revision number (see: foldrender log)" ;; esac; rev="$2"; shift 2 ;;
--version) need $# "$1"; vid="$2"; shift 2 ;;
-*) die 2 "unknown option: $1" ;;
*) targets+=("$1"); shift ;;
esac
done
[ ${#targets[@]} -ge 1 ] || die 2 "usage: foldrender rm PAGE... [--rev REV | --version ID] [--json]"
if [ -n "$rev$vid" ]; then
# One version's body, not the page: its log entry stays, marked deleted.
[ ${#targets[@]} -eq 1 ] || die 2 "--rev and --version name one page"
[ -z "$rev" ] || [ -z "$vid" ] || die 2 "--rev and --version do not go together"
page_id "${targets[0]}"
check_slug "$PAGE_ID"
api_auth DELETE "/api/artifacts/$PAGE_ID/versions/${rev:-$vid}"
[ "$STATUS" != 404 ] || die 7 "no version ${rev:-$vid} of $PAGE_ID (see: foldrender log $PAGE_ID)"
[ "$STATUS" != 409 ] || die 1 "rev $rev is the current revision — the page is it; delete the page itself: foldrender rm $PAGE_ID"
[ "$STATUS" != 410 ] || die 1 "that version's body is already gone; its log entry stays (foldrender log $PAGE_ID)"
expect_2xx
if [ -n "$as_json" ]; then cat "$TMP/body"; printf '\n'; return 0; fi
note "deleted version ${rev:-$vid} of $PAGE_ID; its log entry stays (foldrender log $PAGE_ID)"
return 0
fi
for t in "${targets[@]}"; do
page_id "$t"
check_slug "$PAGE_ID"
api_auth DELETE "/api/artifacts/$PAGE_ID"
if [ "$STATUS" = 404 ]; then
map_forget 2 "$PAGE_ID"
note "not deleted: no page $PAGE_ID among yours"
failed=1
continue
fi
expect_2xx
map_forget 2 "$PAGE_ID"
if [ -n "$as_json" ]; then
cat "$TMP/body"
printf '\n'
continue
fi
url="$(json_get str url 2>/dev/null)"
title="$(json_get str title 2>/dev/null)"
if [ -n "$url" ]; then
printf 'deleted page %s ("%s"); %s no longer opens\n' "$PAGE_ID" "$title" "$url"
else
printf 'deleted page %s\n' "$PAGE_ID"
fi
done
[ "$failed" = 0 ] || exit 1
}
# open ID|URL: prints the page's address and opens it. An id, or a link by id,
# is looked up (format=meta, the facts without the page) for its address; an
# address is used as it is.
cmd_open() {
[ $# -eq 1 ] || die 2 "usage: foldrender open "
local url=""
case "$1" in
http://*/artifacts/-/*|https://*/artifacts/-/*) ;;
http://*/artifacts/*|https://*/artifacts/*) url="$1" ;;
esac
if [ -z "$url" ]; then
page_id "$1"
check_slug "$PAGE_ID"
read_get "/api/artifacts/$PAGE_ID?format=meta"
[ "$STATUS" = 200 ] || read_fail "$PAGE_ID"
url="$(sed -n 's/^url: *//p' "$TMP/body" | head -n 1 | tr -d '\r"')"
case "$url" in
http*) ;;
*) die 1 "the server did not say where $PAGE_ID is" ;;
esac
fi
printf '%s\n' "$url"
open_url "$url"
}
# ------------------------------------------------------------------ read
# read_get PATH: a GET on the read API, with the token when there is one, and
# the page's link token when its address carried one (PAGE_K). Public pages
# need neither, so a missing token is not an error here.
read_get() {
local path="$1"
if [ -n "${PAGE_K:-}" ]; then
case "$path" in
*\?*) path="$path&k=$PAGE_K" ;;
*) path="$path?k=$PAGE_K" ;;
esac
fi
if [ -n "$TOKEN" ]; then
api GET "$path" -H "$(auth_header)"
else
api GET "$path"
fi
}
# read_fail SLUG: the error for a read that did not answer 200.
read_fail() {
local msg
msg="$(error_text)"
case "$STATUS" in
401)
case "$msg" in *"token was rejected"*|unauthorized|"no message") msg="" ;; esac
[ -z "$TOKEN" ] || die 3 "the token was rejected${msg:+ ($msg)}. Run: foldrender login"
die 3 "$1 is not public. Sign in to read it: foldrender login" ;;
404)
[ -z "$TOKEN" ] || die 7 "no page named $1, or it is not shared with the account you are signed in as (see: foldrender whoami)"
die 7 "no page named $1. If it is private or shared, sign in first: foldrender login" ;;
410) die 8 "$msg" ;;
413) die 9 "$1 is too large to read as text ($msg). Read its HTML instead: foldrender read $1 --html" ;;
400) die 2 "$msg" ;;
esac
fail_http
}
# Escapes a string for the replacement side of a sed s|||.
sed_escape() {
printf '%s' "$1" | sed 's/[\\&|]/\\&/g'
}
# save_assets SLUG DIR MODE: saves the page's embedded images and inline SVG
# into DIR, reports each path on stderr and, for the text output, points the
# asset:N references at the saved files.
save_assets() {
local slug="$1" dir="$2" mode="$3" n name type size label path count=0
read_get "/api/artifacts/$slug/assets"
[ "$STATUS" = 200 ] || read_fail "$slug"
cp "$TMP/body" "$TMP/assets"
if [ ! -s "$TMP/assets" ]; then
note "the page has no embedded images or SVG to save"
return 0
fi
mkdir -p "$dir" || die 1 "cannot create $dir"
: > "$TMP/rewrite.sed"
while IFS="$(printf '\t')" read -r n name type size label <&3; do
case "$n" in ''|*[!0-9]*) continue ;; esac
case "$name" in ''|*/*|.*) continue ;; esac
read_get "/api/artifacts/$slug/assets/$n"
[ "$STATUS" = 200 ] || read_fail "$slug"
path="${dir%/}/$name"
cp "$TMP/body" "$path" || die 1 "could not write $path"
note "saved $path ($type, $size bytes)${label:+: $label}"
printf 's|(asset:%s)|(%s)|g\ns|\\[SVG asset:%s\\([]:]\\)|[SVG %s\\1|g\n' \
"$n" "$(sed_escape "$path")" "$n" "$(sed_escape "$path")" >> "$TMP/rewrite.sed"
count=$((count + 1))
done 3< "$TMP/assets"
if [ "$mode" = text ] && [ "$count" -gt 0 ]; then
sed -f "$TMP/rewrite.sed" "$TMP/page" > "$TMP/page.new" && mv -f "$TMP/page.new" "$TMP/page"
fi
}
# read SLUG|URL: the page as Markdown after a few facts about it, stopping
# near FOLDRENDER_READ_MAX_TOKENS (6000) with how much is left, the command
# that goes on, and the page's sections. --outline lists the headings with their sizes, --section N (or
# a heading's text) reads one, --from N goes on where a read stopped, --max-tokens
# and --max-chars set the stop, --all reads everything, --meta full gives every
# fact. --html prints the stored HTML, --json the API's JSON, --save FILE writes
# the stored HTML to a file, --assets DIR also saves embedded images and SVG. A
# PDF published from the app reads as its text, page by page; its page images
# are the assets. A canvas reads as its map, then its cards: --card ID is
# --section for a card, --outline the map alone, --notes adds the annotations
# (for the owner and collaborators).
cmd_read() {
local target="" mode=text assets="" save="" slug query="" part="" outline="" cut="" sect="" notes="" nonotes=""
local maxt="${FOLDRENDER_READ_MAX_TOKENS:-6000}" meta=short
case "$maxt" in ''|*[!0-9]*) maxt=6000 ;; esac
while [ $# -gt 0 ]; do
case "$1" in
--html) mode=html; shift ;;
--json) mode=json; shift ;;
--assets) need $# "$1"; assets="$2"; shift 2 ;;
--save|-o) need $# "$1"; save="$2"; shift 2 ;;
--outline) outline=1; cut=1; shift ;;
--section|--card)
need $# "$1"
[ -z "$sect" ] || die 2 "read takes one --section or --card"
sect=1; part="$part§ion=$(urlencode "$2")"; cut=1; shift 2 ;;
--notes) [ -n "$notes" ] || part="$part¬es=1"; notes=1; shift ;;
--no-notes) nonotes=1; shift ;;
--from)
need $# "$1"
case "$2" in ''|*[!0-9]*) die 2 "--from takes a character offset, from the next: line of a read that stopped" ;; esac
part="$part&from=$2"; cut=1; shift 2 ;;
--max-tokens)
need $# "$1"
case "$2" in ''|*[!0-9]*) die 2 "--max-tokens takes a number (0: no limit)" ;; esac
maxt="$2"; cut=1; shift 2 ;;
--max-chars)
need $# "$1"
case "$2" in ''|*[!0-9]*|0) die 2 "--max-chars takes a number above 0" ;; esac
part="$part&max_chars=$2"; maxt=0; cut=1; shift 2 ;;
--all) maxt=0; shift ;;
--meta)
need $# "$1"
case "$2" in short|full) meta="$2" ;; *) die 2 "--meta takes short or full" ;; esac
shift 2 ;;
-*) die 2 "unknown option: $1" ;;
*) [ -z "$target" ] || die 2 "read takes one page"; target="$1"; shift ;;
esac
done
[ -n "$target" ] || die 2 "usage: foldrender read PAGE [--outline|--section N|--card ID|--from N] [--max-tokens N|--max-chars N|--all] [--no-notes] [--meta full] [--html|--json|--save FILE] [--assets DIR]"
[ -z "$notes" ] || [ -z "$nonotes" ] || die 2 "--notes and --no-notes do not go together"
[ -z "$save" ] || [ "$mode" = text ] || die 2 "--save writes the stored file; drop --html or --json"
[ -z "$save" ] || [ -z "$assets" ] || die 2 "--save and --assets do not go together"
[ -z "$cut" ] || { [ "$mode" != html ] && [ -z "$save" ]; } || die 2 "--outline, --section, --card, --from and --max-* cut the Markdown; drop --html or --save"
[ -z "$notes" ] || { [ "$mode" != html ] && [ -z "$save" ]; } || die 2 "--notes adds the annotations to the Markdown; drop --html or --save"
# read never requests the URL itself: a private page's link redirects to the
# content host, where a client without a cookie jar loses the token and is
# sent back, round and round. An address is resolved to its id instead.
# `read desk` is the signed-in account's desk, as the desk commands' default
# (FOLDRENDER_DESK, when set, names it): not the page whose id is "desk",
# which, ids being one namespace for every account, is someone's.
if [ "$target" = desk ] && [ -n "${FOLDRENDER_DESK:-}" ] && [ "$FOLDRENDER_DESK" != desk ]; then
target="$FOLDRENDER_DESK"
fi
if [ "$target" = desk ]; then
PAGE_K=""
resolve_address /desk desk "this account has no desk (foldrender desk init makes one)"
else
page_id "$target"
fi
slug="$PAGE_ID"
check_slug "$slug"
case "$slug" in
a|api|artifacts|auth|cli) die 2 "$target is not a link to a page" ;;
esac
if [ -n "$save" ]; then
[ ! -e "$save" ] || die 1 "$save already exists; choose another name"
read_get "/api/artifacts/$slug?format=raw"
[ "$STATUS" = 200 ] || read_fail "$slug"
cp "$TMP/body" "$save.part" 2>/dev/null && mv -f "$save.part" "$save" || die 1 "could not write $save"
printf '%s\n' "$save"
return 0
fi
# Signed in, a read brings the page's annotations as they are (notes,
# highlighted passages, boxes, handwriting as recognized) in a Notes section at
# its end, and says how many in its facts: an agent asked about them has them
# without knowing to ask. The server shows them to the page's owner and
# collaborators only, and tells anyone else that it does. (--html asks for the
# stored HTML alone, without these parts.)
if [ -z "$notes" ] && [ -z "$nonotes" ] && [ -n "$TOKEN" ]; then
part="$part¬es=1"
fi
if [ "$mode" = html ]; then
query="?format=html"
else
query="meta=$meta"
[ -n "$outline" ] && query="$query&outline=1"
[ -z "$outline" ] && [ "$maxt" != 0 ] && query="$query&max_tokens=$maxt"
query="?$query$part"
fi
[ "$mode" != json ] || ACCEPT="application/json"
read_get "/api/artifacts/$slug$query"
ACCEPT="text/plain"
[ "$STATUS" = 200 ] || read_fail "$slug"
cp "$TMP/body" "$TMP/page"
[ -z "$assets" ] || save_assets "$slug" "$assets" "$mode"
cat "$TMP/page"
}
# ------------------------------------------------------------------ canvas
# attach KEY FILE: FILE goes along in the request's files under KEY, as a
# card's HTML; it never becomes a page of its own. The limits are checked here,
# before anything is sent. A key attached already is not sent twice.
attach() {
local k bytes
for k in "${ATT_KEYS[@]}"; do
[ "$k" != "$1" ] || return 0
done
[ -f "$2" ] || die 2 "no such file: $2"
[ -r "$2" ] || die 2 "cannot read: $2"
bytes="$(wc -c < "$2" | tr -d ' ')"
[ "$bytes" -gt 0 ] || die 2 "the card is empty: $1"
[ "$bytes" -le "$CARD_MAX_BYTES" ] || die 5 "$1 is $bytes bytes; a card is at most 2 MB ($CARD_MAX_BYTES bytes)"
# sed and awk end a line at a NUL byte, and no HTML page holds one.
[ "$(tr -d '\000' < "$2" | wc -c | tr -d ' ')" = "$bytes" ] || die 2 "$1 holds NUL bytes, so it is not an HTML page"
ATT_BYTES=$((ATT_BYTES + bytes))
[ "$ATT_BYTES" -le "$MAX_BYTES" ] || die 5 "the files are $ATT_BYTES bytes together; a canvas request takes at most 5 MB ($MAX_BYTES bytes) of them"
warn_if_secrets "$2" "$1: "
ATT_KEYS+=("$1")
ATT_PATHS+=("$2")
}
# jkey NAME: the next member's name in the JSON object being written. JSEP is
# the comma before it, empty for the first.
jkey() {
printf '%s"%s":' "$JSEP" "$1"
JSEP=,
}
# files_json: the attached files, as the request's "files" member.
files_json() {
local i=0
jkey files
printf '{'
while [ "$i" -lt "${#ATT_KEYS[@]}" ]; do
[ "$i" -eq 0 ] || printf ','
json_arg "${ATT_KEYS[$i]}"
printf ':'
json_file "${ATT_PATHS[$i]}"
i=$((i + 1))
done
printf '}'
}
# plan_files FILE DIR LABEL: attaches the local files a JSON Canvas plan, or a
# canvas edit patch, names in its "file" fields, each under its name as written
# there, which is relative to DIR. A name that is no file there must be a page's
# id or address, which the server reads. The names are found by a pattern, not
# a JSON parser, so a name written with an escape is refused.
plan_files() {
local v p
tr '\r\n' ' ' < "$1" | grep -oE '"file"[[:space:]]*:[[:space:]]*"([^"\\]|\\.)*"' \
| sed 's/^"file"[[:space:]]*:[[:space:]]*"//; s/"$//' > "$TMP/plan-files"
while IFS= read -r v; do
case "$v" in
*\\*) die 2 "$3: the file name \"$v\" is written with an escape (a double quote, a backslash or \\u); rename the file so that it needs none" ;;
http://*|https://*) continue ;;
/*) p="$v" ;;
*) p="$2/$v" ;;
esac
if [ -f "$p" ]; then
attach "$v" "$p"
else
case "$v" in
''|*[!A-Za-z0-9_-]*) die 2 "$3 names \"$v\": no such file in $2, and not a page's id or address" ;;
esac
fi
done < "$TMP/plan-files"
}
# link_end SPEC: the node a --link spec A:B[:LABEL], or what is left of one,
# starts with. note:N is the Nth --note; a number the Nth card (canvas only);
# then the longest of LINK_ITEMS it starts with, since a path or an address
# may hold colons; then, in canvas edit, an id on the canvas, up to the colon.
# Sets LINK_END (a node's id, or an edit's ref), LINK_REST (what follows the
# colon after it) and LINK_MORE (1 when there was such a colon).
link_end() {
local s="$1" head="${1%%:*}" it best="" bi=0 i=0
case "$s" in
note:*)
head="${s#note:}"
head="${head%%:*}"
case "$head" in ''|*[!0-9]*) die 2 "--link $LINK_SPEC: note: takes the number of a --note, as in note:1" ;; esac
[ "$head" -ge 1 ] && [ "$head" -le "$NOTES_N" ] || die 2 "--link $LINK_SPEC: there is no note:$head; give a --note TEXT for each (there are $NOTES_N)"
if [ "$LINK_MODE" = new ]; then LINK_END="n$head"; else LINK_END="note:$head"; fi
link_rest "$s" "note:$head"
return 0 ;;
esac
if [ "$LINK_MODE" = new ]; then
case "$head" in
''|*[!0-9]*) ;;
*)
[ "$head" -ge 1 ] && [ "$head" -le "${#LINK_ITEMS[@]}" ] || die 2 "--link $LINK_SPEC: there is no card $head; the cards are 1 to ${#LINK_ITEMS[@]}"
LINK_END="c$head"
link_rest "$s" "$head"
return 0 ;;
esac
fi
for it in "${LINK_ITEMS[@]}"; do
i=$((i + 1))
case "$s" in
"$it"|"$it":*) [ "${#it}" -le "${#best}" ] || { best="$it"; bi="$i"; } ;;
esac
done
if [ -n "$best" ]; then
if [ "$LINK_MODE" = new ]; then LINK_END="c$bi"; else LINK_END="$best"; fi
link_rest "$s" "$best"
return 0
fi
if [ "$LINK_MODE" = edit ]; then
case "$head" in
''|*[!A-Za-z0-9_-]*) ;;
*)
if [ "${#head}" -le 64 ]; then
LINK_END="$head"
link_rest "$s" "$head"
return 0
fi ;;
esac
die 2 "--link $LINK_SPEC: \"$head\" is not an id on the canvas, an --add argument or note:N"
fi
die 2 "--link $LINK_SPEC: \"$head\" is not a card's number, a file or page given here, or note:N"
}
# link_rest SPEC TAKEN: LINK_REST and LINK_MORE, from what follows TAKEN, the
# start of SPEC.
link_rest() {
LINK_REST="${1:${#2}}"
LINK_MORE=""
if [ -n "$LINK_REST" ]; then
LINK_MORE=1
LINK_REST="${LINK_REST#:}"
fi
}
# link_parse SPEC: LINK_A, LINK_B and LINK_LABEL from --link A:B[:LABEL]. The
# label is the rest, colons and all.
link_parse() {
LINK_SPEC="$1"
link_end "$1"
LINK_A="$LINK_END"
{ [ -n "$LINK_MORE" ] && [ -n "$LINK_REST" ]; } || die 2 "--link takes A:B or A:B:LABEL, not $1"
link_end "$LINK_REST"
LINK_B="$LINK_END"
LINK_LABEL=""
[ -z "$LINK_MORE" ] || LINK_LABEL="$LINK_REST"
}
# check_request FILE: a canvas request is at most 5 MB and 64 KB, escapes and
# all.
check_request() {
local n
n="$(wc -c < "$1" | tr -d ' ')"
[ "$n" -le $((MAX_BYTES + 65536)) ] || die 5 "the request is $n bytes written as JSON; a canvas request is at most 5 MB and 64 KB ($((MAX_BYTES + 65536)) bytes)"
}
# canvas_url: PAGE_URL, the canvas's address, from the answer.
canvas_url() {
PAGE_URL="$(json_get str page_url)"
[ -n "$PAGE_URL" ] || PAGE_URL="$(json_get str url)"
case "$PAGE_URL" in
http*) ;;
*) die 1 "the server did not say where the canvas is ($(error_text))" ;;
esac
}
# print_map: the canvas's map from the answer, if it has one, ending in a
# newline.
print_map() {
json_get str map > "$TMP/map" || return 0
cat "$TMP/map"
[ -z "$(tail -c 1 "$TMP/map")" ] || printf '\n'
}
# canvas_fail: the error for a canvas request the server refused, for what
# canvas and canvas edit answer alike; the rest as for pages.
canvas_fail() {
local msg retry
msg="$(json_get str message || json_get str error)"
case "$STATUS" in
413) die 5 "too large: ${msg:-the server refused the request}" ;;
429)
retry="$(header_value retry-after)"
case "$retry" in *[!0-9]*) retry="" ;; esac
die 4 "${msg:-too many canvas saves}${retry:+; try again in $retry seconds}" ;;
esac
fail_http
}
# canvas [FILE|PAGE ...|PLAN] [options]: a new canvas. A file goes along in the
# request as a card's HTML and never becomes a page; a page (an id or an
# address) is copied in as a card. Without a plan file the command writes the
# plan: cards c1.. in the order given, notes n1.. (--note), connectors e1..
# (--link), the same ids every time, so the same command again with --name
# finds the canvas unchanged and writes nothing. PLAN, a *.canvas or *.json
# file, is a JSON Canvas plan, sent as it is with the local files it names.
# Prints the canvas's address; stderr says what it holds. --owner is for the
# admin token: the canvas is made for that account.
cmd_canvas() {
if [ "${1:-}" = edit ]; then
shift
cmd_canvas_edit "$@"
return
fi
local items=() refs=() notes=() links=() plan="" it i n as_json="" ctitle="" cowner=""
local vis="" share="" name="" folder="" id rev cards boxes
ATT_KEYS=() ATT_PATHS=() ATT_BYTES=0 JSEP=""
while [ $# -gt 0 ]; do
case "$1" in
--title) need $# "$1"; ctitle="$2"; shift 2 ;;
--name) need $# "$1"; check_name "$2" --name; name="$2"; shift 2 ;;
--folder) need $# "$1"; check_name "$2" --folder; folder="$2"; shift 2 ;;
--note) need $# "$1"; [ -n "$2" ] || die 2 "--note needs some text"; notes+=("$2"); shift 2 ;;
--link) need $# "$1"; links+=("$2"); shift 2 ;;
--private) vis="private"; shift ;;
--link-access) vis="link"; shift ;;
--public) vis="public"; shift ;;
--share) need $# "$1"; share="${2// /}"; shift 2 ;;
--owner) need $# "$1"; check_logins "$2"; case "$2" in *,*) die 2 "--owner takes one GitHub login" ;; esac; cowner="$2"; shift 2 ;;
--json) as_json=1; shift ;;
-*) die 2 "unknown option: $1" ;;
*.canvas|*.json) [ -z "$plan" ] || die 2 "a canvas takes one plan file"; plan="$1"; shift ;;
*) items+=("$1"); shift ;;
esac
done
if [ -n "$share" ]; then
[ -z "$vis" ] || die 2 "--share makes the canvas shared, so drop --private, --link-access or --public"
check_logins "$share"
vis=shared
fi
if [ -n "$plan" ]; then
[ ${#items[@]} -eq 0 ] || die 2 "a plan file holds the whole canvas: name the files and pages in it, not beside it"
{ [ ${#notes[@]} -eq 0 ] && [ ${#links[@]} -eq 0 ]; } || die 2 "a plan file holds the whole canvas: put the notes and connectors in it"
[ -f "$plan" ] || die 2 "no such plan file: $plan"
[ "$(tr -d ' \t\r\n' < "$plan" | head -c 1)" = "{" ] || die 2 "$plan is not a JSON Canvas plan (a JSON object with nodes and edges)"
n="$(tr '\r\n' ' ' < "$plan" | grep -oE '"type"[[:space:]]*:[[:space:]]*"file"' | wc -l | tr -d ' ')"
[ "$n" -le "$CANVAS_MAX_CARDS" ] || die 2 "$plan has $n cards; a canvas holds at most $CANVAS_MAX_CARDS"
plan_files "$plan" "$(dirname "$plan")" "$plan"
fi
[ ${#items[@]} -le "$CANVAS_MAX_CARDS" ] || die 2 "a canvas holds at most $CANVAS_MAX_CARDS cards"
# Everything that needs no network first: the files, then the connectors.
for it in "${items[@]}"; do
if [ -f "$it" ]; then
attach "$it" "$it"
else
case "$it" in
http://*|https://*) ;;
''|*[!A-Za-z0-9_-]*) die 2 "no such file: $it" ;;
esac
fi
done
LINK_MODE=new NOTES_N=${#notes[@]}
LINK_ITEMS=("${items[@]}")
: > "$TMP/edges"
i=0
for it in "${links[@]}"; do
link_parse "$it"
i=$((i + 1))
{
[ "$i" -eq 1 ] || printf ','
printf '{"id":"e%d","fromNode":"%s","toNode":"%s"' "$i" "$LINK_A" "$LINK_B"
[ -z "$LINK_LABEL" ] || { printf ',"label":'; json_arg "$LINK_LABEL"; }
printf '}'
} >> "$TMP/edges"
done
require_token
for it in "${items[@]}"; do
if [ -f "$it" ]; then
refs+=("$it")
else
page_id "$it"
check_slug "$PAGE_ID"
refs+=("$PAGE_ID")
fi
done
{
printf '{'
[ -z "$ctitle" ] || { jkey title; json_arg "$ctitle"; }
[ -z "$name" ] || { jkey name; printf '"%s"' "$name"; }
[ -z "$folder" ] || { jkey folder; printf '"%s"' "$folder"; }
[ -z "$vis" ] || { jkey visibility; printf '"%s"' "$vis"; }
[ -z "$share" ] || { jkey share; json_array "$share"; }
[ -z "$cowner" ] || { jkey owner; printf '"%s"' "$cowner"; }
if [ -n "$plan" ]; then
jkey plan
cat "$plan"
elif [ ${#items[@]} -gt 0 ] || [ ${#notes[@]} -gt 0 ]; then
jkey plan
printf '{"nodes":['
i=0
for it in "${refs[@]}"; do
i=$((i + 1))
[ "$i" -eq 1 ] || printf ','
printf '{"id":"c%d","type":"file","file":' "$i"
json_arg "$it"
printf '}'
done
n=0
for it in "${notes[@]}"; do
n=$((n + 1))
[ $((i + n)) -eq 1 ] || printf ','
printf '{"id":"n%d","type":"text","text":' "$n"
json_arg "$it"
printf '}'
done
printf '],"edges":['
cat "$TMP/edges"
printf ']}'
fi
[ ${#ATT_KEYS[@]} -eq 0 ] || files_json
printf '}'
} > "$TMP/canvas.json"
check_request "$TMP/canvas.json"
ACCEPT="application/json"
api_auth POST /api/canvas -H "Content-Type: application/json" --data-binary "@$TMP/canvas.json"
ACCEPT="text/plain"
case "$STATUS" in
2??) ;;
404)
[ -z "$(json_get str error)" ] || die 7 "$(json_get str error)"
die 1 "this server has no canvases yet" ;;
409) die 1 "$(json_get str error || error_text)" ;;
*) canvas_fail ;;
esac
if [ -n "$as_json" ]; then
cat "$TMP/body"
printf '\n'
return 0
fi
canvas_url
printf '%s\n' "$PAGE_URL"
id="$(json_get str id)"
rev="$(json_get raw rev)"
[ "$rev" != null ] || rev=""
cards="$(json_get raw cards)"
if [ "$(json_get raw changed)" = false ]; then
note "canvas $id unchanged${rev:+, rev $rev}"
elif [ -n "$cards" ]; then
boxes="$(json_get raw boxes)"
note "canvas $id with $cards card(s), $(json_get raw notes) note(s)$([ "${boxes:-0}" = 0 ] || printf ', %s box(es)' "$boxes"), $(json_get raw connectors) connector(s)${rev:+, rev $rev}"
else
note "canvas $id${rev:+, rev $rev}"
if [ -n "$plan" ] || [ $(( ${#items[@]} + ${#notes[@]} )) -gt 0 ]; then
note "warning: the server did not say what the canvas holds, so it may be older than this CLI and have left the cards out. Look: foldrender read $PAGE_URL --outline"
fi
fi
# Its map on stderr, with each card's id, so that a canvas edit needs no read
# first; stdout stays the address alone.
print_map >&2
}
# canvas edit CANVAS [options]: changes a canvas in one request, all of it or
# nothing. --add puts a file (its HTML goes along, as a card only) or a page on
# as a card, --note a note, --link a connector (A and B: ids on the canvas,
# --add arguments, note:N), --rm takes a node, with its connectors, or a
# connector off. --patch sends a JSON patch as it is, with the local files its
# add_nodes name. Prints the address, then the canvas's map as it now is;
# stderr says the revision, what changed and the id each new node got.
cmd_canvas_edit() {
local target="" base="" patch="" plabel="" pdir="" as_json="" adds=() refs=() notes=() links=() rms=()
local it i n cid rev added updated removed
ATT_KEYS=() ATT_PATHS=() ATT_BYTES=0 JSEP=""
while [ $# -gt 0 ]; do
case "$1" in
--base)
need $# "$1"
case "$2" in ''|*[!0-9]*) die 2 "--base takes a revision number (rev: in foldrender read CANVAS --outline)" ;; esac
base="$2"; shift 2 ;;
--add)
need $# "$1"
for it in "${adds[@]}"; do
[ "$it" != "$2" ] || die 2 "--add $2 twice: a card added is named by its argument, so each must differ"
done
adds+=("$2"); shift 2 ;;
--note) need $# "$1"; [ -n "$2" ] || die 2 "--note needs some text"; notes+=("$2"); shift 2 ;;
--link) need $# "$1"; links+=("$2"); shift 2 ;;
--rm)
need $# "$1"
case "$2" in ''|*[!A-Za-z0-9_-]*) die 2 "--rm takes the id of a card, note, box or connector on the canvas, or a unique start of it: $2" ;; esac
rms+=("$2"); shift 2 ;;
--patch) need $# "$1"; [ -z "$patch" ] || die 2 "canvas edit takes one --patch"; patch="$2"; shift 2 ;;
--json) as_json=1; shift ;;
-*) die 2 "unknown option: $1" ;;
*) [ -z "$target" ] || die 2 "canvas edit takes one canvas"; target="$1"; shift ;;
esac
done
[ -n "$target" ] || die 2 "usage: foldrender canvas edit CANVAS [--base REV] [--add FILE|PAGE]... [--note TEXT]... [--link A:B[:LABEL]]... [--rm ID]... | --patch FILE|-"
n=$(( ${#adds[@]} + ${#notes[@]} + ${#links[@]} + ${#rms[@]} ))
if [ -n "$patch" ]; then
[ "$n" -eq 0 ] || die 2 "--patch holds the whole change: give --base with it, but not --add, --note, --link or --rm"
if [ "$patch" = - ]; then
cat > "$TMP/patch.json"
pdir=. plabel="the patch"
else
[ -f "$patch" ] || die 2 "no such file: $patch"
cp "$patch" "$TMP/patch.json" || die 1 "cannot read $patch"
pdir="$(dirname "$patch")" plabel="$patch"
fi
case "$(tr -d ' \t\r\n' < "$TMP/patch.json" | head -c 2)" in
'{}') die 2 "$plabel changes nothing" ;;
'{'*) [ "$(tr -d ' \t\r\n' < "$TMP/patch.json" | tail -c 1)" = "}" ] || die 2 "$plabel is not a JSON object" ;;
*) die 2 "--patch takes a JSON object: {\"add_nodes\": [...], \"add_edges\": [...], \"remove_ids\": [...], ...}" ;;
esac
if [ -n "$base" ] && json_get raw base "$TMP/patch.json" > /dev/null; then
die 2 "$plabel has its own base; drop --base"
fi
# A patch that carries its own files is sent as it is.
json_get raw files "$TMP/patch.json" > /dev/null || plan_files "$TMP/patch.json" "$pdir" "$plabel"
elif [ "$n" -eq 0 ]; then
die 2 "nothing to change: give --add, --note, --link, --rm or --patch"
fi
[ ${#adds[@]} -le "$CANVAS_MAX_CARDS" ] || die 2 "a canvas holds at most $CANVAS_MAX_CARDS cards"
for it in "${adds[@]}"; do
if [ -f "$it" ]; then
attach "$it" "$it"
else
case "$it" in
http://*|https://*) ;;
''|*[!A-Za-z0-9_-]*) die 2 "no such file: $it" ;;
esac
fi
done
LINK_MODE=edit NOTES_N=${#notes[@]}
LINK_ITEMS=("${adds[@]}")
: > "$TMP/edges"
i=0
for it in "${links[@]}"; do
link_parse "$it"
i=$((i + 1))
{
[ "$i" -eq 1 ] || printf ','
printf '{"ref":"link:%d","from":' "$i"
json_arg "$LINK_A"
printf ',"to":'
json_arg "$LINK_B"
[ -z "$LINK_LABEL" ] || { printf ',"label":'; json_arg "$LINK_LABEL"; }
printf '}'
} >> "$TMP/edges"
done
require_token
page_id "$target"
check_slug "$PAGE_ID"
cid="$PAGE_ID"
for it in "${adds[@]}"; do
if [ -f "$it" ]; then
refs+=("$it")
else
page_id "$it"
check_slug "$PAGE_ID"
refs+=("$PAGE_ID")
fi
done
if [ -n "$patch" ]; then
{
# The patch as it is, with the base and the files put in before its
# closing brace.
awk '{ s = s (NR > 1 ? "\n" : "") $0 } END { i = length(s); while (i > 0 && substr(s, i, 1) != "}") i--; printf "%s", substr(s, 1, i - 1) }' "$TMP/patch.json"
JSEP=,
[ -z "$base" ] || { jkey base; printf '%s' "$base"; }
[ ${#ATT_KEYS[@]} -eq 0 ] || files_json
printf '}'
} > "$TMP/edit.json"
else
{
printf '{'
[ -z "$base" ] || { jkey base; printf '%s' "$base"; }
if [ $(( ${#adds[@]} + ${#notes[@]} )) -gt 0 ]; then
jkey add_nodes
printf '['
i=0
while [ "$i" -lt "${#adds[@]}" ]; do
[ "$i" -eq 0 ] || printf ','
printf '{"ref":'
json_arg "${adds[$i]}"
printf ',"type":"file","file":'
json_arg "${refs[$i]}"
printf '}'
i=$((i + 1))
done
n=0
for it in "${notes[@]}"; do
n=$((n + 1))
[ $((i + n)) -eq 1 ] || printf ','
printf '{"ref":"note:%d","type":"text","text":' "$n"
json_arg "$it"
printf '}'
done
printf ']'
fi
if [ ${#rms[@]} -gt 0 ]; then
jkey remove_ids
printf '['
i=0
for it in "${rms[@]}"; do
[ "$i" -eq 0 ] || printf ','
printf '"%s"' "$it"
i=$((i + 1))
done
printf ']'
fi
if [ ${#links[@]} -gt 0 ]; then
jkey add_edges
printf '['
cat "$TMP/edges"
printf ']'
fi
[ ${#ATT_KEYS[@]} -eq 0 ] || files_json
printf '}'
} > "$TMP/edit.json"
fi
check_request "$TMP/edit.json"
ACCEPT="application/json"
api_auth POST "/api/canvas/$cid/edit" -H "Content-Type: application/json" --data-binary "@$TMP/edit.json"
ACCEPT="text/plain"
case "$STATUS" in
2??) ;;
404) die 7 "no canvas named $cid among yours" ;;
409)
# Stale: the server's message, then the canvas's map as it now is.
note "$(json_get str message || printf 'the edit did not apply (%s)' "$(error_text)")"
print_map >&2
exit 10 ;;
400) die 10 "the edit did not apply ($(json_get str message || json_get str error || error_text))" ;;
*) canvas_fail ;;
esac
if [ -n "$as_json" ]; then
cat "$TMP/body"
printf '\n'
return 0
fi
canvas_url
printf '%s\n' "$PAGE_URL"
print_map
rev="$(json_get raw rev)"
[ "$rev" != null ] || rev="?"
if [ "$(json_get raw changed)" = false ]; then
note "unchanged, rev $rev"
else
if [ -n "$patch" ]; then
added=$(( $(json_get len add_nodes "$TMP/patch.json" || echo 0) + $(json_get len add_edges "$TMP/patch.json" || echo 0) ))
updated=$(( $(json_get len update_nodes "$TMP/patch.json" || echo 0) + $(json_get len update_edges "$TMP/patch.json" || echo 0) ))
else
added=$(( ${#adds[@]} + ${#notes[@]} + ${#links[@]} )) updated=0
fi
removed="$(json_get len removed || echo 0)"
note "rev $rev: added $added, removed $removed$([ "$updated" -eq 0 ] || printf ', updated %s' "$updated")"
fi
json_get pairs ids | while IFS="$(printf '\t')" read -r it n; do
note "$it -> $n"
done
}
# ------------------------------------------------------------------ answers
# answers PAGE: the page's questions (its data-fr-choice blocks) and what the
# people who may answer them (its owner, its collaborators) answered, on the
# page, in a browser: the latest per person, with the suggestion, right or
# wrong, a note, and what it changed from. Text by default (the server lays it
# out), --json for the API's answer. An answer counts once its undo window
# (6 s) is over; reading them with a token marks them read by an agent, which
# the page shows. --wait waits until there is an answer no agent has read yet
# (or, with --after N, one newer than answer N), up to --timeout seconds
# (default 600; exit 11 when none came). --notify NAME says whom the owner's
# relay tells of new answers on this page ("-" no one, "default" the page's own
# fr-choice-notify), --clear deletes every answer on the page (owner).
# answers --inbox lists the answers on your pages nobody has been told of yet,
# one a line: id, page, whom to tell, question, answer, who, when; --ack
# ID[,ID...] --to NAME marks them told (a relay's two steps).
cmd_answers() {
local target="" mode=text wait="" timeout=600 after="" notify="" clear="" inbox="" ack="" to="" slug q started left n body
while [ $# -gt 0 ]; do
case "$1" in
--json) mode=json; shift ;;
--wait) wait=1; shift ;;
--timeout)
need $# "$1"
case "$2" in ''|*[!0-9]*) die 2 "--timeout takes seconds" ;; esac
timeout="$2"; shift 2 ;;
--after)
need $# "$1"
case "$2" in ''|*[!0-9]*) die 2 "--after takes an answer's number (the latest: line of the last answers)" ;; esac
after="$2"; shift 2 ;;
--notify) need $# "$1"; notify="$2"; shift 2 ;;
--clear) clear=1; shift ;;
--inbox) inbox=1; shift ;;
--ack) need $# "$1"; ack="$2"; shift 2 ;;
--to) need $# "$1"; to="$2"; shift 2 ;;
-*) die 2 "unknown option: $1" ;;
*) [ -z "$target" ] || die 2 "answers takes one page"; target="$1"; shift ;;
esac
done
require_token
[ "$mode" != json ] || ACCEPT="application/json"
if [ -n "$inbox" ] || [ -n "$ack" ]; then
[ -z "$target" ] || die 2 "--inbox and --ack are about all your pages; drop the page"
if [ -n "$ack" ]; then
case "$ack" in *[!0-9,]*|,*|*,) die 2 "--ack takes answer ids, comma separated" ;; esac
[ -z "$to" ] || case "$to" in *[!A-Za-z0-9_.@-]*) die 2 "--to is a name: letters, digits, . _ - @" ;; esac
printf '{"ids":[%s],"to":%s}' "$ack" "$(json_arg "$to")" > "$TMP/req"
api_auth POST "/api/choices/inbox" -H "Content-Type: application/json" --data-binary "@$TMP/req"
expect_2xx
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
printf 'marked %s told\n' "$(sed -n 's/.*"marked" *: *\([0-9]*\).*/\1/p' "$TMP/body")"
return 0
fi
api_auth GET "/api/choices/inbox"
expect_2xx
cat "$TMP/body"
[ "$mode" != json ] || printf '\n'
return 0
fi
[ -n "$target" ] || die 2 "usage: foldrender answers PAGE [--wait [--after N] [--timeout S]] [--json] | --notify NAME | --clear; foldrender answers --inbox | --ack IDS --to NAME"
page_id "$target"
slug="$PAGE_ID"
check_slug "$slug"
q="${PAGE_K:+?k=$PAGE_K}"
if [ -n "$clear" ]; then
api_auth DELETE "/api/artifacts/$slug/choices$q"
[ "$STATUS" != 404 ] || die 7 "no page named $slug among yours"
expect_2xx
printf 'cleared %s answers on %s\n' "$(sed -n 's/.*"cleared" *: *\([0-9]*\).*/\1/p' "$TMP/body")" "$target"
return 0
fi
if [ -n "$notify" ]; then
case "$notify" in
default) body='{"to":null}' ;;
-) body='{"to":"-"}' ;;
*[!A-Za-z0-9_.@-]*) die 2 "--notify is a name: letters, digits, . _ - @ (or - for no one, default for the page's own)" ;;
*) body="{\"to\":\"$notify\"}" ;;
esac
printf '%s' "$body" > "$TMP/req"
api_auth PUT "/api/artifacts/$slug/choices/notify$q" -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "$STATUS" != 404 ] || die 7 "no page named $slug among yours"
expect_2xx
case "$notify" in
default) printf 'new answers on %s are told as the page says (its fr-choice-notify)\n' "$target" ;;
-) printf 'new answers on %s are told to no one\n' "$target" ;;
*) printf 'new answers on %s are told to %s\n' "$target" "$notify" ;;
esac
return 0
fi
started="$(date +%s)"
while :; do
q="${PAGE_K:+k=$PAGE_K&}"
[ -z "$after" ] || q="${q}after=$after&"
if [ -n "$wait" ]; then
left=$((timeout - ($(date +%s) - started)))
[ "$left" -gt 0 ] || left=0
[ "$left" -le 25 ] || left=25
q="${q}wait=$left&"
fi
q="${q%&}"
api_auth GET "/api/artifacts/$slug/choices${q:+?$q}"
case "$STATUS" in
404) die 7 "no page named $slug, or you may not answer it (the owner and collaborators may)" ;;
esac
expect_2xx
n="$(header_value x-fr-new)"
if [ -z "$wait" ] || [ "${n:-0}" -gt 0 ]; then
cat "$TMP/body"
[ "$mode" != json ] || printf '\n'
return 0
fi
if [ $(($(date +%s) - started)) -ge "$timeout" ]; then
die 11 "no new answer on $target in $timeout seconds (foldrender answers $target shows them all)"
fi
done
}
# foldrender desk ...: the desk, a private page of yours that shows what waits
# on you, what runs, the machines and seats, and what comes next. Your agents
# write it with tokens of its own (desk token add); you answer on the page. A
# desk token is read from FOLDRENDER_DESK_TOKEN, never from the command line;
# without one, the saved sign-in is used.
DESK_ID=""
DESK_KEPT=""
desk_auth() {
if [ -n "${FOLDRENDER_DESK_TOKEN:-}" ]; then
case "$FOLDRENDER_DESK_TOKEN" in frdk_*) ;; *) die 2 "FOLDRENDER_DESK_TOKEN is a desk token (frdk_...)" ;; esac
( umask 077 && printf 'Authorization: Bearer %s\n' "$FOLDRENDER_DESK_TOKEN" > "$TMP/dauth" ) || die 1 "cannot write $TMP/dauth"
printf '@%s' "$TMP/dauth"
else
require_token
auth_header
fi
}
# desk_send METHOD PATH [curl args...]: the request to the desk's PATH. A kept
# id (DESK_KEPT) the server answers 404 for is tried once more as "desk", the
# account's own desk. When that answers, the kept id is stale (the desk was
# deleted, maybe made again elsewhere) and is dropped; desk init keeps the
# desk's id again. When it does not (the 404 was for what PATH names), the kept
# id stays.
desk_send() {
local method="$1" path="$2" kept
shift 2
api "$method" "/api/desk/$DESK_ID$path" "$@"
[ "$STATUS" = 404 ] && [ -n "$DESK_KEPT" ] || return 0
kept="$DESK_ID"; DESK_ID=desk; DESK_KEPT=""
api "$method" "/api/desk/$DESK_ID$path" "$@"
if [ "${STATUS#2}" != "$STATUS" ]; then
rm -f "$DESK_FILE"
note "the desk id kept for this account ($kept) is gone; this went to your own desk (foldrender desk init keeps its id again)"
else
DESK_ID="$kept"
fi
}
desk_api() {
local method="$1" path="$2"
shift 2
desk_send "$method" "$path" -H "$(desk_auth)" "$@"
}
# A desk's answer that is not 2xx, with what the server said.
desk_fail() {
local msg why
msg="$(error_text)"
# The server says why on a message: line (or in a JSON message field).
why="$(sed -n -e 's/^message: *//p' -e 's/.*"message" *: *"\([^"]*\)".*/\1/p' "$TMP/body" | head -n 1)"
case "$STATUS" in
401) die 3 "not signed in, or the token was rejected ($msg)" ;;
404) die 7 "no desk $DESK_ID that this token or sign-in may use (foldrender desk init makes one; --desk names another)" ;;
409) die 1 "$msg${why:+: $why}" ;;
422) die 1 "refused ($msg)${why:+: $why}" ;;
429) die 1 "${why:-$msg}" ;;
*) fail_http ;;
esac
}
# A date and time as given (with its zone), or a date (end of that day) or a
# date and time without a zone, in this computer's zone.
desk_when() {
local z
z="$(date +%z)"
z="${z%??}:${z#???}"
case "$1" in
????-??-??) printf '%sT23:59:00%s' "$1" "$z" ;;
????-??-??T??:??) printf '%s:00%s' "$1" "$z" ;;
????-??-??T??:??:??) printf '%s%s' "$1" "$z" ;;
????-??-??T*Z|????-??-??T*[+-]??:??) printf '%s' "$1" ;;
*) die 2 "a time is 2026-10-23, 2026-10-23T10:00 or an ISO date and time: $1" ;;
esac
}
# links as JSON: each --link URL[=LABEL].
desk_links() {
local out="" l url label
for l in "$@"; do
case "$l" in *=*) url="${l%=*}"; label="${l##*=}" ;; *) url="$l"; label="" ;; esac
case "$label" in */*) url="$l"; label="" ;; esac
out="$out${out:+,}{\"url\":$(json_arg "$url"),\"label\":$(json_arg "${label:-page}")}"
done
printf '[%s]' "$out"
}
desk_written() {
[ "$1" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
grep -o '"key":"[^"]*","rev":[0-9]*' "$TMP/body" | sed 's/"key":"\([^"]*\)","rev":\([0-9]*\)/wrote \1 (revision \2)/'
grep -q '"unchanged":[1-9]' "$TMP/body" && printf 'unchanged: %s\n' "$(sed -n 's/.*"unchanged":\([0-9]*\).*/\1/p' "$TMP/body")"
return 0
}
# The desk's second version ("Desk v2" in the Worker's README): questions as
# cards of one shape (a one-line question, your suggestion, 0 to 2 other
# options; the owner answers agree, another option, "not now" or their own
# words), the owner's answers sent in batches when they press Send (nothing
# else sends one), and those batches handed to their agents from the outbox.
# Same sign-in or desk
# token as the first version's; the paths are the account's, not a desk id's.
desk2_api() {
local method="$1" path="$2"
shift 2
api "$method" "/api/desk$path" -H "$(desk_auth)" "$@"
}
desk2_fail() {
local msg field
msg="$(json_field error)"
field="$(json_field field)"
case "$STATUS" in
401|403) die 3 "${msg:-not signed in, or the token was rejected}" ;;
404) die 7 "${msg:-not found}: no such card or batch, or the desk's second version is not on for this account" ;;
409|422) die 1 "refused${field:+ ($field)}: ${msg:-$(error_text)}" ;;
429) die 1 "${msg:-$(error_text)}" ;;
*) fail_http ;;
esac
}
cmd_desk() {
local sub="${1:-}" mode=text key="" a
[ $# -eq 0 ] || shift
# A machine's, not a desk page's: no --desk.
case "$sub" in
agent) cmd_desk_agent "$@"; return ;;
machine) cmd_desk_machine "$@"; return ;;
owner) cmd_desk_owner "$@"; return ;;
image) cmd_desk_image "$@"; return ;;
mem) cmd_desk_mem "$@"; return ;;
esac
# The default desk is the account's own: the id desk init kept for a saved
# account, or "desk", which the API reads as the caller's own (the desk a
# desk token is for; the account's when there is no saved account).
# --desk and FOLDRENDER_DESK name another.
DESK_ID="${FOLDRENDER_DESK:-}"
# --desk and --json may come anywhere.
local rest=()
while [ $# -gt 0 ]; do
case "$1" in
--desk) need $# "$1"; DESK_ID="$2"; shift 2 ;;
--json) mode=json; shift ;;
*) rest+=("$1"); shift ;;
esac
done
# With neither, the kept id; a desk token is for one desk, which is the
# server's to say, so the kept id of whichever account is saved here is not
# read for it. Only a kept id that is not one gives way to "desk": what
# --desk or FOLDRENDER_DESK says is read as given below (an address is turned
# into its id, anything else that is not an id refused).
DESK_KEPT=""
if [ -z "$DESK_ID" ]; then
if [ -n "$DESK_FILE" ] && [ -z "${FOLDRENDER_DESK_TOKEN:-}" ]; then
DESK_ID="$(head -n 1 "$DESK_FILE" 2>/dev/null | tr -d ' \t\r\n')"
case "$DESK_ID" in ''|*[!A-Za-z0-9_-]*) DESK_ID="" ;; *) DESK_KEPT=1 ;; esac
fi
[ -n "$DESK_ID" ] || DESK_ID=desk
fi
set -- ${rest[@]+"${rest[@]}"}
case "$DESK_ID" in http://*|https://*) page_id "$DESK_ID"; DESK_ID="$PAGE_ID" ;; esac
check_slug "$DESK_ID"
case "$sub" in
init)
local name="" title="" lang=""
while [ $# -gt 0 ]; do
case "$1" in
--name) need $# "$1"; name="$2"; shift 2 ;;
--title) need $# "$1"; title="$2"; shift 2 ;;
--lang) need $# "$1"; lang="$2"; shift 2 ;;
*) die 2 "desk init takes --name, --title, --lang en|zh" ;;
esac
done
[ -z "$name" ] || check_name "$name" --name
printf '{%s}' "$(printf '"name":%s' "$(json_arg "${name:-desk}")")${title:+,\"title\":$(json_arg "$title")}${lang:+,\"lang\":$(json_arg "$lang")}" > "$TMP/req"
api_auth POST "/api/desk" -H "Content-Type: application/json" --data-binary "@$TMP/req"
expect_2xx
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
# The address on a line of its own: the answer is one line of JSON with
# no newline at its end, and sed keeps the line as it came.
printf '%s\n' "$(sed -n 's/.*"url" *: *"\([^"]*\)".*/\1/p' "$TMP/body")"
local id
id="$(sed -n 's/.*"slug" *: *"\([^"]*\)".*/\1/p' "$TMP/body")"
if grep -q '"created":true' "$TMP/body"; then note "made your desk, id $id; give it agents with: foldrender desk token add --role agent --name NAME"
else note "you have this desk already, id $id"; fi
# This account's desk commands default to the desk named desk: when the
# account is saved here, its id is kept beside its token, so the other
# desk commands send it without --desk.
if [ "${name:-desk}" = desk ] && [ -n "$DESK_FILE" ]; then
printf '%s\n' "$id" > "$DESK_FILE" 2>/dev/null || true
fi
# A desk of another name is named with --desk.
[ "${name:-desk}" = desk ] || note "its id is $id: pass --desk $id (or set FOLDRENDER_DESK=$id) to the other desk commands"
;;
token)
local op="${1:-ls}" role="" name=""
[ $# -eq 0 ] || shift
case "$op" in
add)
while [ $# -gt 0 ]; do
case "$1" in
--role) need $# "$1"; role="$2"; shift 2 ;;
--name) need $# "$1"; name="$2"; shift 2 ;;
*) die 2 "desk token add --role feed|agent --name NAME" ;;
esac
done
case "$role" in feed|agent) ;; *) die 2 "--role is feed (sends snapshots, reads nothing) or agent (asks, tasks, reads the answers)" ;; esac
[ -n "$name" ] || die 2 "--name names who holds it, e.g. --name secretary"
printf '{"role":"%s","name":%s}' "$role" "$(json_arg "$name")" > "$TMP/req"
require_token
desk_send POST /tokens -H "$(auth_header)" -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || desk_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
sed -n 's/.*"token" *: *"\(frdk_[^"]*\)".*/\1/p' "$TMP/body"
note "a $role token for desk $DESK_ID, id $(sed -n 's/.*"id" *: *"\([0-9a-f]*\)".*/\1/p' "$TMP/body"); shown once. Keep it where the agent reads it (FOLDRENDER_DESK_TOKEN from a secret store or a 600 file), never in a command line. Revoke: foldrender desk token rm ID"
;;
ls)
ACCEPT="text/plain"
[ "$mode" != json ] || ACCEPT="application/json"
require_token
desk_send GET /tokens -H "$(auth_header)"
[ "${STATUS#2}" != "$STATUS" ] || desk_fail
cat "$TMP/body"
;;
rm)
[ $# -ge 1 ] || die 2 "desk token rm ID"
case "$1" in *[!0-9a-f]*|'') die 2 "a token's id is 16 hex characters (desk token ls)" ;; esac
require_token
desk_send DELETE "/tokens/$1" -H "$(auth_header)"
[ "${STATUS#2}" != "$STATUS" ] || desk_fail
printf 'revoked %s\n' "$1"
;;
*) die 2 "desk token add | ls | rm" ;;
esac
;;
ask|task|timer|known)
key="${1:-}"
[ $# -eq 0 ] || shift
case "$key" in ''|-*) die 2 "desk $sub KEY ...: KEY names the thing (letters, digits, . _ : -), the same key again changes it" ;; esac
local title="" product="" state="" context="" suggest="" deadline="" need="" heavy="" input="" device="" minutes="" session="" next="" at="" star="" match="" until="" reply="" base=""
local opts=() links=()
while [ $# -gt 0 ]; do
case "$1" in
--title) need $# "$1"; title="$2"; shift 2 ;;
--product) need $# "$1"; product="$2"; shift 2 ;;
--state) need $# "$1"; state="$2"; shift 2 ;;
--context) need $# "$1"; context="$2"; shift 2 ;;
--suggest) need $# "$1"; suggest="$2"; shift 2 ;;
--option) need $# "$1"; opts+=("$2"); shift 2 ;;
--deadline) need $# "$1"; deadline="$(desk_when "$2")" || exit 2; shift 2 ;;
--hands) need=hands; shift ;;
--heavy) heavy=1; shift ;;
--input) input=1; shift ;;
--device) need $# "$1"; device="$2"; shift 2 ;;
--minutes) need $# "$1"; minutes="$2"; shift 2 ;;
--session) need $# "$1"; session="$2"; shift 2 ;;
--next) need $# "$1"; next="$2"; shift 2 ;;
--at) need $# "$1"; at="$(desk_when "$2")" || exit 2; shift 2 ;;
--star) star=1; shift ;;
--match) need $# "$1"; match="$2"; shift 2 ;;
--note) need $# "$1"; [ "$sub" = known ] && title="$2" || die 2 "--note is for desk known and desk done"; shift 2 ;;
--until) need $# "$1"; until="$(desk_when "$2")" || exit 2; shift 2 ;;
--link) need $# "$1"; links+=("$2"); shift 2 ;;
--reply-url) need $# "$1"; reply="$2"; shift 2 ;;
--base) need $# "$1"; case "$2" in ''|*[!0-9]*) die 2 "--base is the revision you read" ;; esac; base="$2"; shift 2 ;;
*) die 2 "desk $sub: unknown option $1 (foldrender help desk)" ;;
esac
done
[ -n "$title" ] || die 2 "desk $sub needs --title (desk known: --note)"
case "$minutes" in ''|[0-9]|[0-9][0-9]|[0-9][0-9][0-9]) ;; *) die 2 "--minutes is a number" ;; esac
[ ${#opts[@]} -le 3 ] || die 2 "at most 3 --option"
local body="" i=1 o ol=""
for o in ${opts[@]+"${opts[@]}"}; do ol="$ol${ol:+,}{\"id\":\"o$i\",\"label\":$(json_arg "$o")}"; i=$((i + 1)); done
[ -z "$context" ] || body="$body,\"context\":$(json_arg "$context")"
[ -z "$suggest" ] || body="$body,\"suggest\":$(json_arg "$suggest")"
[ -z "$ol" ] || body="$body,\"options\":[$ol]"
[ -z "$deadline" ] || body="$body,\"deadline\":$(json_arg "$deadline")"
[ -z "$need" ] || body="$body,\"need\":\"hands\""
[ -z "$heavy" ] || body="$body,\"heavy\":true"
[ -z "$input" ] || body="$body,\"input\":true"
[ -z "$device" ] || body="$body,\"device\":$(json_arg "$device")"
[ -z "$minutes" ] || body="$body,\"minutes\":$minutes"
[ -z "$session" ] || body="$body,\"session\":$(json_arg "$session")"
[ -z "$next" ] || body="$body,\"next_check\":$(json_arg "$next")"
[ -z "$at" ] || body="$body,\"at\":$(json_arg "$at")"
[ -z "$star" ] || body="$body,\"star\":true"
[ -z "$match" ] || body="$body,\"match\":$(json_arg "$match")"
[ -z "$until" ] || body="$body,\"until\":$(json_arg "$until")"
[ -z "$reply" ] || body="$body,\"reply_url\":$(json_arg "$reply")"
[ ${#links[@]} -eq 0 ] || body="$body,\"links\":$(desk_links "${links[@]}")"
[ "$sub" != task ] || [ -n "$state" ] || state=doing
printf '{"items":[{"key":%s,"kind":"%s","title":%s%s%s%s,"body":{%s}}]}' \
"$(json_arg "$key")" "$sub" "$(json_arg "$title")" "${product:+,\"product\":$(json_arg "$product")}" "${state:+,\"state\":$(json_arg "$state")}" "${base:+,\"base\":$base}" "${body#,}" > "$TMP/req"
desk_api POST /items -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || desk_fail
desk_written "$mode"
;;
done|rm)
local keys=() note="" k list=""
while [ $# -gt 0 ]; do
case "$1" in
--note) need $# "$1"; [ "$sub" = done ] || die 2 "--note is for desk done"; note="$2"; shift 2 ;;
-*) die 2 "desk $sub KEY... ${sub/done/[--note TEXT]}" ;;
*) keys+=("$1"); shift ;;
esac
done
[ ${#keys[@]} -ge 1 ] || die 2 "desk $sub KEY..."
for k in "${keys[@]}"; do
if [ "$sub" = done ]; then list="$list${list:+,}{\"key\":$(json_arg "$k")${note:+,\"note\":$(json_arg "$note")}}"
else list="$list${list:+,}$(json_arg "$k")"; fi
done
if [ "$sub" = done ]; then printf '{"done":[%s]}' "$list" > "$TMP/req"; else printf '{"remove":[%s]}' "$list" > "$TMP/req"; fi
desk_api POST /items -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || desk_fail
if [ "$mode" = json ]; then cat "$TMP/body"; printf '\n'
elif [ "$sub" = done ]; then printf 'done: %s\n' "${keys[*]}"
else printf 'removed: %s\n' "$(sed -n 's/.*"removed":\[\([^]]*\)\].*/\1/p' "$TMP/body" | tr -d '"' | sed 's/,/ /g')"; fi
;;
feed)
local source="${1:-}" file="${2:-}"
case "$source" in ''|*[!a-z0-9-]*|-*) die 2 "desk feed SOURCE FILE.json|-: SOURCE is lowercase letters, digits and -" ;; esac
[ -n "$file" ] || die 2 "desk feed SOURCE FILE.json|- (the file: {\"every_s\": 60, \"items\": [...]})"
if [ "$file" = - ]; then cat > "$TMP/feed"; file="$TMP/feed"; fi
[ -f "$file" ] || die 2 "no file $file"
desk_api PUT "/feed/$source" -H "Content-Type: application/json" --data-binary "@$file"
[ "${STATUS#2}" != "$STATUS" ] || desk_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
printf 'feed %s: %s things, %s changed, %s removed\n' "$source" "$(sed -n 's/.*"items":\([0-9]*\).*/\1/p' "$TMP/body")" "$(sed -n 's/.*"changed":\([0-9]*\).*/\1/p' "$TMP/body")" "$(sed -n 's/.*"removed":\([0-9]*\).*/\1/p' "$TMP/body")"
;;
ls|'')
[ "$mode" = json ] && ACCEPT="application/json" || ACCEPT="text/plain"
desk_api GET ""
[ "${STATUS#2}" != "$STATUS" ] || desk_fail
cat "$TMP/body"
[ "$mode" != json ] || printf '\n'
;;
inbox)
local q=""
while [ $# -gt 0 ]; do
case "$1" in
--all) q="${q}all=1&"; shift ;;
--after) need $# "$1"; case "$2" in ''|*[!0-9]*) die 2 "--after takes an answer's id" ;; esac; q="${q}after=$2&"; shift 2 ;;
*) die 2 "desk inbox [--all] [--after ID] [--json]" ;;
esac
done
q="${q%&}"
[ "$mode" = json ] && ACCEPT="application/json" || ACCEPT="text/plain"
desk_api GET "/inbox${q:+?$q}"
[ "${STATUS#2}" != "$STATUS" ] || desk_fail
cat "$TMP/body"
[ "$mode" != json ] || printf '\n'
;;
ack)
local id="${1:-}" st="" result=""
[ $# -eq 0 ] || shift
case "$id" in ''|*[!0-9]*) die 2 "desk ack ID --sent | --doing | --done TEXT (ID from desk inbox)" ;; esac
while [ $# -gt 0 ]; do
case "$1" in
--sent) st=sent; shift ;;
--doing) st=doing; shift ;;
--done) need $# "$1"; st=done; result="$2"; shift 2 ;;
*) die 2 "desk ack ID --sent | --doing | --done TEXT" ;;
esac
done
[ -n "$st" ] || die 2 "desk ack ID --sent | --doing | --done TEXT"
printf '{"state":"%s"%s}' "$st" "${result:+,\"result\":$(json_arg "$result")}" > "$TMP/req"
desk_api POST "/actions/$id/state" -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || desk_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
printf 'answer %s marked %s\n' "$id" "$st"
;;
card)
key="${1:-}"
[ $# -eq 0 ] || shift
# The room's rule for a key (desk-cards.js KEY_RE), checked here too: the
# key goes into the path of --close, where ? or / would change the request.
local key_re='^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$'
[[ "$key" =~ $key_re ]] || die 2 "desk card KEY --question Q --suggest S ...: KEY names the card (1 to 80 letters, digits, . _ : -, starting with a letter or digit), the same key again rewrites it"
local question="" suggest="" context="" product="" by="" machine="" deadline="" heavy="" close="" why=""
local opts=() links=() imgs=()
while [ $# -gt 0 ]; do
case "$1" in
--question) need $# "$1"; question="$2"; shift 2 ;;
--suggest) need $# "$1"; suggest="$2"; shift 2 ;;
--context) need $# "$1"; context="$2"; shift 2 ;;
--option) need $# "$1"; opts+=("$2"); shift 2 ;;
--product) need $# "$1"; product="$2"; shift 2 ;;
--by) need $# "$1"; by="$2"; shift 2 ;;
--machine) need $# "$1"; machine="$2"; shift 2 ;;
--deadline) need $# "$1"; deadline="$(desk_when "$2")" || exit 2; shift 2 ;;
--heavy) heavy=1; shift ;;
--link) need $# "$1"; links+=("$2"); shift 2 ;;
--image) need $# "$1"; imgs+=("$2"); shift 2 ;;
--close) close=1; shift ;;
--done) [ -n "$close" ] || die 2 "--done goes with --close"; why=done; shift ;;
--title|--hands|--input|--device|--minutes) die 2 "desk card has no $1: every card is answered the same way (agree, another option, not now, or the owner's own words); the question is --question, and what you would do is --suggest" ;;
*) die 2 "desk card: unknown option $1 (foldrender help desk)" ;;
esac
done
if [ -n "$close" ]; then
[ -z "$question$suggest$context$product$by$machine$deadline$heavy" ] && [ ${#opts[@]} -eq 0 ] && [ ${#links[@]} -eq 0 ] && [ ${#imgs[@]} -eq 0 ] \
|| die 2 "--close takes only --done (a card is written, or taken off, not both)"
desk2_api DELETE "/cards/$key${why:+?why=done}"
[ "${STATUS#2}" != "$STATUS" ] || desk2_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
# An answer the owner picked on it and had not sent stays theirs (the
# room's closeCard says one stands, unsent_draft): the card stays on
# their page with it, to send or drop.
local kept
kept="$(json_get raw unsent_draft 2>/dev/null)" || kept=""
if [ "$kept" = true ]; then
printf "took %s off the desk; the owner's draft on it was kept, not sent (theirs to send or drop)\n" "$key"
else
printf 'took %s off the desk\n' "$key"
fi
return 0
fi
[ -n "$question" ] || die 2 "desk card needs --question: the question, on one line"
[ -n "$suggest" ] || die 2 "desk card needs --suggest: what you would do (the owner agrees with one tap)"
[ ${#opts[@]} -le 2 ] || die 2 "at most 2 --option (the page adds \"not now, ask me tomorrow\" itself)"
[ ${#imgs[@]} -le 3 ] || die 2 "at most 3 --image (pictures to choose between)"
# --image FILE=LABEL (or FILE): every picture and its label checked here
# first, then each sent as desk image does, before the card that shows
# them. A label is one line of at most 40 characters, as the room has it
# (characters, not bytes: each UTF-8 byte that does not start one is dropped
# before counting, whatever the locale).
local il="" im ifile ilabel n
for im in ${imgs[@]+"${imgs[@]}"}; do
desk_image_arg "$im"
case "$ilabel" in *$'\n'*|*$'\r'*) die 2 "--image FILE=LABEL: a label is one line" ;; esac
n="$(printf '%s' "$ilabel" | LC_ALL=C tr -d '\200-\277' | wc -c | tr -d ' ')"
[ "$n" -le 40 ] || die 2 "--image FILE=LABEL: a label is at most 40 characters ($n here)"
desk_image_check "$ifile"
done
for im in ${imgs[@]+"${imgs[@]}"}; do
desk_image_arg "$im"
desk_image_put "$ifile"
il="$il${il:+,}{\"id\":$(json_get raw id),\"label\":$(json_arg "$ilabel")}"
done
local body ol="" o
for o in ${opts[@]+"${opts[@]}"}; do ol="$ol${ol:+,}$(json_arg "$o")"; done
body="\"key\":$(json_arg "$key"),\"question\":$(json_arg "$question"),\"suggest\":$(json_arg "$suggest")"
[ -z "$context" ] || body="$body,\"context\":$(json_arg "$context")"
[ -z "$ol" ] || body="$body,\"options\":[$ol]"
[ -z "$product" ] || body="$body,\"product\":$(json_arg "$product")"
[ -z "$by" ] || body="$body,\"by\":$(json_arg "$by")"
[ -z "$machine" ] || body="$body,\"machine\":$(json_arg "$machine")"
[ -z "$deadline" ] || body="$body,\"deadline\":$(json_arg "$deadline")"
[ -z "$heavy" ] || body="$body,\"heavy\":true"
[ ${#links[@]} -eq 0 ] || body="$body,\"links\":$(desk_links "${links[@]}")"
[ -z "$il" ] || body="$body,\"images\":[$il]"
printf '{"cards":[{%s}]}' "$body" > "$TMP/req"
desk2_api POST /cards -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || desk2_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
grep -q '"code":"full"' "$TMP/body" && die 1 "the desk holds as many open questions as it can: take one off first (desk card KEY --close)"
printf '%s %s (revision %s)\n' "$(sed -n 's/.*"result":"\([a-z_]*\)".*/\1/p' "$TMP/body")" "$key" "$(sed -n 's/.*"rev":\([0-9]*\).*/\1/p' "$TMP/body")"
;;
cards)
[ $# -eq 0 ] || die 2 "desk cards [--json]"
[ "$mode" = json ] && ACCEPT="application/json" || ACCEPT="text/plain"
desk2_api GET /cards
[ "${STATUS#2}" != "$STATUS" ] || desk2_fail
cat "$TMP/body"
[ "$mode" != json ] || printf '\n'
;;
outbox)
if [ "${1:-}" = ack ]; then
shift
[ $# -ge 1 ] || die 2 "desk outbox ack ID... (the #IDs desk outbox printed)"
local ids="" i
for i in "$@"; do
i="${i#\#}"
case "$i" in ''|*[!0-9]*) die 2 "an outbox event's id is a number (desk outbox prints #ID)" ;; esac
# 10#: "01" is a number, but not a JSON number.
ids="$ids${ids:+,}$((10#$i))"
done
printf '{"ids":[%s]}' "$ids" > "$TMP/req"
desk2_api POST /outbox/ack -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || desk2_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
printf 'handed on: %s\n' "$(sed -n 's/.*"acked":\[\([0-9,]*\)\].*/\1/p' "$TMP/body")"
return 0
fi
[ $# -eq 0 ] || die 2 "desk outbox [--json] | desk outbox ack ID..."
[ "$mode" = json ] && ACCEPT="application/json" || ACCEPT="text/plain"
desk2_api GET /outbox
[ "${STATUS#2}" != "$STATUS" ] || desk2_fail
cat "$TMP/body"
[ "$mode" != json ] || printf '\n'
;;
answers)
# What the owner sent, for this agent: its own cursor, kept by the desk
# for its token (none needed here), or --since NEXT from an earlier read.
local since="" alimit=""
while [ $# -gt 0 ]; do
case "$1" in
--since) need $# "$1"; case "$2" in ''|*[!0-9]*) die 2 "--since is the next an earlier read printed (a number)" ;; esac; since="$2"; shift 2 ;;
--limit) need $# "$1"; case "$2" in ''|*[!0-9]*) die 2 "--limit is a number" ;; esac; alimit="$2"; shift 2 ;;
*) die 2 "desk answers [--since NEXT] [--limit N] [--json]" ;;
esac
done
[ "$mode" = json ] && ACCEPT="application/json" || ACCEPT="text/plain"
local aq="${since:+since=$since}"
[ -z "$alimit" ] || aq="$aq${aq:+&}limit=$alimit"
desk2_api GET "/answers${aq:+?$aq}"
[ "${STATUS#2}" != "$STATUS" ] || desk2_fail
cat "$TMP/body"
[ "$mode" != json ] || printf '\n'
;;
mark)
# Which answers: --key each (with its own --result, one call a result),
# or --all of them alike; a batch of several with neither is refused
# (2026-10-30: one done with one result over a batch wrote it on every
# answer). --correct: an answer this token marked, set as it should be.
local batch="${1:-}" st="${2:-}" result="" all="" correct=""
[ $# -lt 2 ] || shift 2
case "$batch" in ''|*[!0-9]*) die 2 "desk mark BATCH read|doing|done [--result TEXT] [--key KEY]... [--all] [--correct]" ;; esac
case "$st" in read|doing|done) ;; *) die 2 "desk mark BATCH read|doing|done [--result TEXT] [--key KEY]... [--all] [--correct]" ;; esac
local keys=() kl="" k marked unchanged
while [ $# -gt 0 ]; do
case "$1" in
--result) need $# "$1"; result="$2"; shift 2 ;;
--key) need $# "$1"; keys+=("$2"); shift 2 ;;
--all) all=1; shift ;;
--correct) correct=1; shift ;;
*) die 2 "desk mark: unknown option $1 (foldrender help desk)" ;;
esac
done
[ -z "$result" ] || [ "$st" != read ] || die 2 "--result goes with doing or done"
for k in ${keys[@]+"${keys[@]}"}; do kl="$kl${kl:+,}$(json_arg "$k")"; done
[ -z "$all" ] || [ -z "$kl" ] || die 2 "--key or --all, not both"
printf '{"state":"%s"%s%s%s%s}' "$st" "${result:+,\"result\":$(json_arg "$result")}" "${kl:+,\"keys\":[$kl]}" "${all:+,\"all\":true}" "${correct:+,\"correct\":true}" > "$TMP/req"
desk2_api POST "/batches/$batch/mark" -H "Content-Type: application/json" --data-binary "@$TMP/req"
if [ "${STATUS#2}" = "$STATUS" ]; then
# Which answers, and whose: the desk's own words (they name the keys).
case "$(json_field code)" in keys_needed|bad_keys|not_yours) die 1 "refused: $(json_field error)" ;; esac
desk2_fail
fi
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
marked="$(sed -n 's/.*"marked":\[\([^]]*\)\].*/\1/p' "$TMP/body" | tr -d '"')"
unchanged="$(sed -n 's/.*"unchanged":\[\([^]]*\)\].*/\1/p' "$TMP/body" | tr -d '"')"
if [ -n "$marked" ] && [ -n "$correct" ]; then printf 'batch %s: %s corrected to %s\n' "$batch" "$marked" "$st"
elif [ -n "$marked" ]; then printf 'batch %s: %s marked %s\n' "$batch" "$marked" "$st"; fi
if [ -n "$unchanged" ]; then
if grep -q '"hint"' "$TMP/body"; then printf 'batch %s: %s unchanged (marked already, with another result: the one who marked it changes it with --correct)\n' "$batch" "$unchanged"
else printf 'batch %s: %s unchanged (marked so already)\n' "$batch" "$unchanged"; fi
fi
;;
batches)
local limit=""
while [ $# -gt 0 ]; do
case "$1" in
--limit) need $# "$1"; case "$2" in ''|*[!0-9]*) die 2 "--limit is a number" ;; esac; limit="$2"; shift 2 ;;
*) die 2 "desk batches [--limit N] [--json]" ;;
esac
done
[ "$mode" = json ] && ACCEPT="application/json" || ACCEPT="text/plain"
desk2_api GET "/batches${limit:+?limit=$limit}"
[ "${STATUS#2}" != "$STATUS" ] || desk2_fail
cat "$TMP/body"
[ "$mode" != json ] || printf '\n'
;;
*) die 2 "unknown desk command: $sub (foldrender help desk)" ;;
esac
}
# A machine's name on the desk: 1 to 32 letters, digits, dots, underscores and
# hyphens, starting with a letter or a digit.
check_machine_name() {
case "$1" in
''|[!A-Za-z0-9]*|*[!A-Za-z0-9._-]*) die 2 "a machine's name is letters, digits, . _ and -, starting with a letter or digit: $1" ;;
esac
[ "${#1}" -le 32 ] || die 2 "a machine's name is at most 32 characters: $1"
}
# This computer's name, as a machine's name: its short host name, with what a
# machine's name cannot hold made a hyphen.
default_machine_name() {
local n
n="$(hostname -s 2>/dev/null || hostname 2>/dev/null || printf 'machine')"
n="$(printf '%s' "$n" | tr -c 'A-Za-z0-9._-' '-' | cut -c 1-32)"
while :; do case "$n" in [!A-Za-z0-9]*) n="${n#?}" ;; *) break ;; esac; done
printf '%s' "${n:-machine}"
}
# machine_add NAME [REPLACE]: a new machine on the desk, its token in the body
# ($TMP/body, this run's private folder).
machine_add() {
printf '{"name":%s%s}' "$(json_arg "$1")" "${2:+,\"replace\":true}" > "$TMP/req"
ACCEPT="application/json"
api_auth POST "/api/desk/machines" -H "Content-Type: application/json" --data-binary "@$TMP/req"
ACCEPT="text/plain"
case "$STATUS" in
201|200) grep -q '"token" *: *"frdm_' "$TMP/body" || die 1 "the desk made machine $1 but sent no token" ;;
409) die 1 "the desk has a machine named $1 already (id $(json_get str id 2>/dev/null || printf '?')); --replace gives it a new token and revokes the old one" ;;
403) if grep -q '"full"' "$TMP/body"; then die 4 "$(error_text)"; fi; fail_http ;;
400) die 2 "$(error_text)" ;;
404) die 1 "this account has no desk with machines ($(error_text))" ;;
*) fail_http ;;
esac
}
# foldrender desk machine add NAME [--replace] | ls | rm ID: the machines that
# report what runs on them to the desk, each with a token of its own (frdm_...)
# that can do nothing but that. add prints the token once, alone on stdout.
# A picture a question may show (/desk/images): the file's bytes, its type
# by its name; prints the image's id (its SHA-256) for the card's images.
cmd_desk_image() {
local file="" mode=text type size
while [ $# -gt 0 ]; do
case "$1" in
--json) mode=json; shift ;;
-*) die 2 "desk image FILE [--json]" ;;
*) [ -z "$file" ] || die 2 "desk image takes one FILE"; file="$1"; shift ;;
esac
done
[ -n "$file" ] || die 2 "which picture? foldrender desk image FILE (a PNG, JPEG or WebP, at most 200 KB)"
desk_image_check "$file"
desk_image_put "$file"
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
json_get str id
printf '\n'
note "put it in a question: \"images\": [{\"id\": \"\", \"label\": \"A ...\"}] (at most 3), or let desk card --image FILE=LABEL do both"
}
# desk_image_check FILE: a picture the desk may take (its name and size), or
# exit 2 before anything is sent. desk_image_put FILE: sends it; the answer
# ({id, ...}) is in $TMP/body.
# --image FILE[=LABEL]: sets ifile and ilabel (the caller's). A file that is
# there is the whole argument, whatever it holds; otherwise it is split at the
# first =, and only when there is one (a missing FILE alone is said to be
# missing, not taken for a label).
desk_image_arg() {
ifile="$1"; ilabel=""
case "$1" in *=*) [ -f "$1" ] || { ifile="${1%%=*}"; ilabel="${1#*=}"; } ;; esac
}
desk_image_check() {
local file="$1" size
[ -f "$file" ] && [ -r "$file" ] || die 2 "cannot read $file"
case "$(printf '%s' "$file" | tr '[:upper:]' '[:lower:]')" in
*.png|*.jpg|*.jpeg|*.webp) ;;
*) die 2 "a question's picture is a .png, .jpg or .webp file (an SVG is not taken: export it as a PNG)" ;;
esac
size="$(wc -c < "$file" | tr -d ' ')"
[ "$size" -le 204800 ] || die 2 "$file is $((size / 1024)) KB: a question's picture is at most 200 KB (make it smaller first)"
}
desk_image_put() {
local file="$1" type
case "$(printf '%s' "$file" | tr '[:upper:]' '[:lower:]')" in
*.png) type=image/png ;;
*.jpg|*.jpeg) type=image/jpeg ;;
*.webp) type=image/webp ;;
esac
ACCEPT="application/json"
api POST "/api/desk/images" -H "$(desk_auth)" -H "Content-Type: $type" --data-binary "@$file"
ACCEPT="text/plain"
case "$STATUS" in
2??) ;;
401) die 3 "not signed in, or the token was rejected ($(error_text))" ;;
403) die 1 "$(error_text)" ;;
404) die 7 "no desk for this account (foldrender desk init makes one)" ;;
413) die 1 "the desk takes a picture of at most 200 KB" ;;
415) die 1 "$file is not a PNG, JPEG or WebP inside" ;;
429) die 1 "$(error_text)" ;;
*) fail_http ;;
esac
}
# What the desk calls its owner (/desk/owner): the owner's sign-in or
# token, or the desk's agent token (FOLDRENDER_DESK_TOKEN).
cmd_desk_owner() {
local name="" set="" mode=text n
while [ $# -gt 0 ]; do
case "$1" in
--name) need $# "$1"; name="$2"; set=1; shift 2 ;;
--clear) name=""; set=1; shift ;;
--json) mode=json; shift ;;
*) die 2 "desk owner [--name NAME | --clear] [--json]" ;;
esac
done
ACCEPT="application/json"
if [ -n "$set" ]; then
printf '{"name":%s}' "$(json_arg "$name")" > "$TMP/req"
api POST "/api/desk/owner" -H "$(desk_auth)" -H "Content-Type: application/json" --data-binary "@$TMP/req"
else
api GET "/api/desk/owner" -H "$(desk_auth)"
fi
ACCEPT="text/plain"
case "$STATUS" in
2??) ;;
401) die 3 "not signed in, or the token was rejected ($(error_text))" ;;
403) die 3 "this token may not name the desk's owner: use your sign-in, your API token or the desk's agent token ($(error_text))" ;;
404) die 7 "no desk for this account (foldrender desk init makes one)" ;;
422) die 1 "refused: the name is one line of at most 32 characters, and nothing that looks like a secret" ;;
*) fail_http ;;
esac
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
n="$(json_get str name 2>/dev/null || true)"
if [ -n "$n" ]; then printf 'the desk calls you %s\n' "$n"; else printf 'no name: the desk shows your login'"'"'s first letter\n'; fi
}
# foldrender desk mem ...: the desk's memory (src/desk-mem.js on the Worker):
# sessions, events and observations kept in your desk's room — what your
# agents saw, and the decisions only your answers on the desk make. Read with the
# desk's agent token (FOLDRENDER_DESK_TOKEN) or your sign-in, as the cards are.
mem_fail() {
local msg
msg="$(error_text)"
case "$STATUS" in
401) die 3 "not signed in, or the token was rejected ($msg)" ;;
403) die 3 "agents write the desk's memory with a token (foldrender desk token add --role agent), not from a page ($msg)" ;;
404) die 7 "${msg:-not found}: this account has no desk app, or no such memory path (foldrender.dev/desk)" ;;
413) die 5 "too large: $msg" ;;
429) die 4 "$msg" ;;
400|422) die 1 "refused ($msg)$(sed -n 's/.*"field" *: *"\([^"]*\)".*/, field \1/p' "$TMP/body" | head -n 1)" ;;
# The desk's storage is full (all desks', or this one's): its own sentence.
507) die 1 "$msg" ;;
*) fail_http ;;
esac
}
cmd_desk_mem() {
case "${1:-}" in --help|-h|help) cmd_help desk-mem; return 0 ;; esac
local sub="${1:-status}" mode=text a q="" limit="" kind="" basis="" repo="" file="" key="" text="" source="" by="" via="" at="" ref="" session="" agent="" machine="" expires=""
[ $# -eq 0 ] || shift
[ "$sub" != install ] || { cmd_desk_mem_install "$@"; return; }
[ "$sub" != todo ] || { cmd_desk_mem_todo "$@"; return; }
local rest=()
while [ $# -gt 0 ]; do
case "$1" in
--json) mode=json; shift ;;
--limit) need $# "$1"; limit="$2"; shift 2 ;;
--kind) need $# "$1"; kind="$2"; shift 2 ;;
--basis) need $# "$1"; basis="$2"; shift 2 ;;
--repo) need $# "$1"; repo="$2"; shift 2 ;;
--file) need $# "$1"; file="$2"; shift 2 ;;
--key) need $# "$1"; key="$2"; shift 2 ;;
--text) need $# "$1"; text="$2"; shift 2 ;;
--source) need $# "$1"; source="$2"; shift 2 ;;
--by) need $# "$1"; by="$2"; shift 2 ;;
--via) need $# "$1"; via="$2"; shift 2 ;;
--at) need $# "$1"; at="$(desk_when "$2")"; shift 2 ;;
--ref) need $# "$1"; ref="$2"; shift 2 ;;
--session) need $# "$1"; session="$2"; shift 2 ;;
--agent) need $# "$1"; agent="$2"; shift 2 ;;
--machine) need $# "$1"; machine="$2"; shift 2 ;;
--expires) need $# "$1"; expires="$(desk_when "$2")"; shift 2 ;;
--help|-h) cmd_help desk-mem; return 0 ;;
-) rest+=("$1"); shift ;;
-*) die 2 "desk mem $sub: unknown option $1 (foldrender help desk-mem)" ;;
*) rest+=("$1"); shift ;;
esac
done
set -- ${rest[@]+"${rest[@]}"}
mem_opt() { [ -z "$2" ] || printf ',"%s":%s' "$1" "$(json_arg "$2")"; }
case "$sub" in
status)
api GET "/api/desk/mem/status" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
cat "$TMP/body"; printf '\n'
;;
inject)
q=""; [ -z "$repo" ] || q="?repo=$(urlencode "$repo")"
api GET "/api/desk/mem/inject$q" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
cat "$TMP/body"
;;
search)
local qs="?q=$(urlencode "${1:-}")"
[ -z "$kind" ] || qs="$qs&kind=$(urlencode "$kind")"
[ -z "$basis" ] || qs="$qs&basis=$(urlencode "$basis")"
[ -z "$limit" ] || qs="$qs&limit=$(urlencode "$limit")"
api GET "/api/desk/mem/search$qs" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
cat "$TMP/body"; printf '\n'
;;
obs)
[ -n "${1:-}" ] || die 2 "desk mem obs ID[,ID...]"
api GET "/api/desk/mem/obs?id=$(urlencode "$1")" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
cat "$TMP/body"; printf '\n'
;;
sessions)
api GET "/api/desk/mem/sessions" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
cat "$TMP/body"; printf '\n'
;;
events)
local qs=""
[ -z "$session" ] || qs="${qs}&session=$(urlencode "$session")"
[ -z "$limit" ] || qs="${qs}&limit=$(urlencode "$limit")"
api GET "/api/desk/mem/events${qs:+?${qs#&}}" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
cat "$TMP/body"; printf '\n'
;;
say)
# One event a hook would send: {kind, text, agent?, machine?, repo?,
# session?, by?, via?, at?, ref?}. kind is prompt|stop|summary|mark|tool.
kind="${kind:-mark}"
case "$kind" in prompt|stop|summary|mark|tool) ;; *) die 2 "desk mem say --kind prompt|stop|summary|mark|tool" ;; esac
[ -n "$text" ] || text="${1:-}"
if [ "$file" = - ] || [ "$text" = - ]; then text="$(cat)"
elif [ -n "$file" ]; then [ -f "$file" ] || die 2 "no file $file"; text="$(cat "$file")"
fi
[ -n "$text" ] || die 2 "desk mem say TEXT|--text T|--file F|- [--kind K] [--agent A] [--machine M] [--repo R] [--session ID] [--by N] [--via V] [--at WHEN] [--ref R]"
printf '{"kind":%s,"text":%s%s%s%s%s%s%s%s%s}' "$(json_arg "$kind")" "$(json_arg "$text")" "$(mem_opt agent "$agent")" "$(mem_opt machine "$machine")" "$(mem_opt repo "$repo")" "$(mem_opt session "$session")" "$(mem_opt by "$by")" "$(mem_opt via "$via")" "$(mem_opt at "$at")" "$(mem_opt ref "$ref")" > "$TMP/req"
api POST "/api/desk/mem/events" -H "$(desk_auth)" -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
printf 'added: %s event(s)\n' "$(sed -n 's/.*"added":\([0-9]*\).*/\1/p' "$TMP/body")"
;;
flush)
# A hook's batch of events: lines of JSON or {events:[...]} on a file/-.
[ -n "$file" ] || file="${1:--}"
if [ "$file" = - ]; then cat > "$TMP/flush"; file="$TMP/flush"; fi
[ -f "$file" ] || die 2 "no file $file"
api POST "/api/desk/mem/events" -H "$(desk_auth)" -H "Content-Type: application/json" --data-binary "@$file"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
printf 'added: %s, unchanged: %s\n' "$(sed -n 's/.*"added":\([0-9]*\).*/\1/p' "$TMP/body")" "$(sed -n 's/.*"unchanged":\([0-9]*\).*/\1/p' "$TMP/body")"
;;
note)
# One observation: {kind, basis, source, quote, text}: a discovery, a
# change, a note or an ask. Never a decision (the API refuses one):
# those come only from the owner's answers on the desk.
kind="${kind:-note}"; basis="${basis:-inferred}"
[ -n "${text:-}" ] || die 2 "desk mem note TEXT --kind K --basis B --source S [--quote-file F] [--at WHEN] [--expires WHEN]"
local quote=""
[ -z "$file" ] || quote="$(cat "$file")"
printf '{"kind":%s,"basis":%s,"source":%s,"quote":%s,"text":%s%s%s}' "$(json_arg "$kind")" "$(json_arg "$basis")" "$(json_arg "${source:-cli}")" "$(json_arg "$quote")" "$(json_arg "$text")" "$(mem_opt at "$at")" "${expires:+,\"expires_at\":$(json_arg "$expires")}" > "$TMP/req"
api POST "/api/desk/mem/obs" -H "$(desk_auth)" -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
printf 'obs %s\n' "$(sed -n 's/.*"id":\([0-9]*\).*/\1/p' "$TMP/body" | head -n 1)"
;;
import)
# Observations from a file, one JSON object a line (as `desk mem note`
# sends one), 100 to a request at most. With basis "imported" and a source of
# its own, each is kept once: the file sent again (after a stop, a
# quota or a failure) adds only what is not there yet, and what is
# already there costs none of the day's writes. One the owner forgot
# (desk mem forget) stays forgotten.
[ -n "$file" ] || file="${1:--}"
if [ "$file" = - ]; then cat > "$TMP/import"; file="$TMP/import"; fi
[ -f "$file" ] || die 2 "no file $file"
grep -v '^[[:space:]]*$' "$file" > "$TMP/import.lines" || true
# Each item's line in the file, blank lines counted, for where it stopped.
grep -n -v '^[[:space:]]*$' "$file" | cut -d: -f1 > "$TMP/import.nums" || true
local total sent=0 added=0 unchanged=0 forgotten=0 part n again at
total=$(wc -l < "$TMP/import.lines" | tr -d ' ')
[ "$total" -gt 0 ] || die 2 "desk mem import: $file has no lines (one JSON object a line)"
# At most 100 to a request and under the API's 64 KB a request (bytes,
# so C); a line that is more on its own goes alone and is refused.
LC_ALL=C awk -v pre="$TMP/import.part." -v max=60000 '
{ b = length($0) + 1
if (n == 100 || (n > 0 && size + b > max)) { close(f); k++; n = 0; size = 0 }
f = sprintf("%s%06d", pre, k); print > f; n++; size += b }' "$TMP/import.lines"
for part in "$TMP"/import.part.*; do
{ printf '{"obs":['; paste -sd, "$part"; printf ']}'; } > "$TMP/req"
n=$(wc -l < "$part" | tr -d ' ')
api POST "/api/desk/mem/obs" -H "$(desk_auth)" -H "Content-Type: application/json" --data-binary "@$TMP/req"
if [ "${STATUS#2}" = "$STATUS" ]; then
[ "$mode" = json ] || [ "$total" -le 100 ] || printf '\n' >&2
# A day's quota: the same command after 00:00 UTC goes on (what is
# kept costs nothing then); sent again today it is refused again.
again="Run it again to go on."
[ "$STATUS" != 429 ] || ! day_limit || again="Run it again after 00:00 UTC to go on (what is already in costs nothing); until then it is refused again."
at=$((sent + $(sed -n -e 's/^index: *\([0-9][0-9]*\).*/\1/p' -e 's/.*"index":\([0-9][0-9]*\).*/\1/p' "$TMP/body" | head -n 1 | grep . || echo 0) + 1))
printf 'desk mem import: stopped at line %s of %s; added %s, unchanged %s before it; %s of %s items are still to go. %s\n' \
"$(sed -n "${at}p" "$TMP/import.nums")" "$(awk 'END { print NR }' "$file")" "$added" "$unchanged" "$((total - at + 1))" "$total" "$again" >&2
mem_fail
fi
added=$((added + $(grep -o '"result":"added"' "$TMP/body" | wc -l)))
unchanged=$((unchanged + $(grep -o '"result":"unchanged"' "$TMP/body" | wc -l)))
forgotten=$((forgotten + $(grep -o '"result":"forgotten"' "$TMP/body" | wc -l)))
sent=$((sent + n))
[ "$mode" = json ] || [ "$total" -le 100 ] || printf '\r%s of %s' "$sent" "$total" >&2
done
[ "$mode" = json ] || [ "$total" -le 100 ] || printf '\n' >&2
[ "$mode" = json ] && { printf '{"lines":%s,"added":%s,"unchanged":%s,"forgotten":%s}\n' "$total" "$added" "$unchanged" "$forgotten"; return 0; }
printf 'added: %s, unchanged: %s%s (of %s)\n' "$added" "$unchanged" "$([ "$forgotten" = 0 ] || printf ', forgotten: %s' "$forgotten")" "$total"
;;
forget)
# Hide what came from one source (a card's answer, a message).
[ -n "${source:-}" ] || die 2 "desk mem forget --source SOURCE (e.g. card:KEY:bN, thread:KEY)"
printf '{"source":%s}' "$(json_arg "$source")" > "$TMP/req"
api POST "/api/desk/mem/obs/hide" -H "$(desk_auth)" -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
printf 'hidden: %s\n' "$source"
;;
*) die 2 "unknown desk mem command: $sub (foldrender help desk-mem)" ;;
esac
}
# The repository this command runs in, as the memory hook names it (its
# event.py): the folder that holds its .git, which a worktree shares with its
# main checkout; else this folder. Nothing when that is more than 128 bytes.
mem_repo_here() {
local common top r
common="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || common=""
common="${common%/}"
if [ -n "$common" ]; then
if [ "$(basename "$common")" = .git ]; then top="$(dirname "$common")"; else top="$common"; fi
r="$(basename "$top")"
else
r="$(basename "${PWD%/}")"
fi
[ -z "$r" ] || [ "${#r}" -gt 128 ] || printf '%s' "$r"
}
# foldrender desk mem todo ...: an agent's own to-do lists in the desk's
# memory (mem_todo on the Worker), what claude-mem's work_state kept: lists of
# items, each todo, doing, done or dropped, with a few fields of their own.
# The same list and item written again is the later write's (its status, its
# fields over the kept ones); done or dropped closes. Never on the owner's
# page: the agents read them, and a new session in the list's repository is
# handed the open ones touched in the last 7 days.
cmd_desk_mem_todo() {
case "${1:-}" in --help|-h|help) cmd_help desk-mem; return 0 ;; esac
local sub="${1:-ls}" mode=text status="" repo="" list="" all="" limit="" offset="" by="" at="" from="" f k v fj="" item=""
[ $# -eq 0 ] || shift
local rest=() fields=()
while [ $# -gt 0 ]; do
case "$1" in
--json) mode=json; shift ;;
--status) need $# "$1"; status="$2"; shift 2 ;;
--dropped) status=dropped; shift ;;
--field) need $# "$1"; fields+=("$2"); shift 2 ;;
--repo) need $# "$1"; repo="$2"; shift 2 ;;
--list) need $# "$1"; list="$2"; shift 2 ;;
--all) all=1; shift ;;
--limit) need $# "$1"; limit="$2"; shift 2 ;;
--offset) need $# "$1"; offset="$2"; shift 2 ;;
--by) need $# "$1"; by="$2"; shift 2 ;;
--at) need $# "$1"; at="$(desk_when "$2")"; shift 2 ;;
--from) need $# "$1"; from="$2"; shift 2 ;;
--help|-h) cmd_help desk-mem; return 0 ;;
-) rest+=("$1"); shift ;;
-*) die 2 "desk mem todo $sub: unknown option $1 (foldrender help desk-mem)" ;;
*) rest+=("$1"); shift ;;
esac
done
set -- ${rest[@]+"${rest[@]}"}
mem_opt() { [ -z "$2" ] || printf ',"%s":%s' "$1" "$(json_arg "$2")"; }
case "$sub" in
set|close)
# set LIST [ITEM]: the list's own row when there is no item.
list="${1:-}"; item="${2:-}"
[ -n "$list" ] || die 2 "desk mem todo $sub LIST [ITEM]$([ "$sub" = set ] && printf ' [--status todo|doing|done|dropped] [--field NAME=VALUE]... [--repo R]' || printf ' [--dropped]')"
[ $# -le 2 ] || die 2 "desk mem todo $sub LIST [ITEM]: one item a call (quote it: \"$3\" is a third word)"
if [ "$sub" = close ]; then
case "$status" in ""|done|dropped) status="${status:-done}" ;; *) die 2 "desk mem todo close: --status done|dropped" ;; esac
[ ${#fields[@]} -eq 0 ] || die 2 "desk mem todo close takes no --field (desk mem todo set LIST ITEM --status done --field ...)"
fi
case "$status" in ""|todo|doing|done|dropped) ;; *) die 2 "desk mem todo set --status todo|doing|done|dropped" ;; esac
for f in ${fields[@]+"${fields[@]}"}; do
case "$f" in *=*) ;; *) die 2 "desk mem todo set --field NAME=VALUE (NAME= takes a field away), not $f" ;; esac
k="${f%%=*}"; v="${f#*=}"
[ -n "$k" ] || die 2 "desk mem todo set --field NAME=VALUE: a field has a name"
fj="$fj,$(json_arg "$k"):$(json_arg "$v")"
done
[ -z "$fj" ] || fj=",\"fields\":{${fj#,}}"
printf '{"list":%s%s%s%s%s%s%s%s}' "$(json_arg "$list")" "$(mem_opt item "$item")" "$(mem_opt status "$status")" \
"$fj" "$(mem_opt repo "$repo")" "$(mem_opt default_repo "$(mem_repo_here)")" "$(mem_opt by "$by")" "$(mem_opt at "$at")" > "$TMP/req"
api POST "/api/desk/mem/todo" -H "$(desk_auth)" -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
printf '%s: %s%s (%s)%s\n' "$(json_get str result)" "$list" "${item:+ / $item}" "$(json_get str status)" \
"$(json_get raw open | grep -q true || printf ', list closed')"
;;
ls)
local qs=""
[ -z "${list:-${1:-}}" ] || qs="$qs&list=$(urlencode "${list:-$1}")"
[ -z "$repo" ] || qs="$qs&repo=$(urlencode "$repo")"
[ -z "$all" ] || qs="$qs&all=1"
[ -z "$limit" ] || qs="$qs&limit=$(urlencode "$limit")"
[ -z "$offset" ] || qs="$qs&offset=$(urlencode "$offset")"
[ "$mode" = json ] && ACCEPT="application/json" || ACCEPT="text/plain"
api GET "/api/desk/mem/todo${qs:+?${qs#&}}" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
cat "$TMP/body"
[ "$mode" != json ] || printf '\n'
;;
rm)
[ -n "${1:-}" ] && [ $# -eq 1 ] || die 2 "desk mem todo rm LIST (the whole list, its items with it)"
printf '{"list":%s,"remove":true}' "$(json_arg "$1")" > "$TMP/req"
api POST "/api/desk/mem/todo" -H "$(desk_auth)" -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
printf 'removed: %s (%s rows)\n' "$1" "$(json_get raw rows)"
;;
import)
# Lines as set sends one ({"list","item","status","fields","repo","at"};
# scripts/claude-mem-export.mjs --work-state makes the file from
# claude-mem's work-state lists), in order, 100 (and 60 KB) a request, as
# they are (application/x-ndjson): the desk reads each line alone, so no
# line's text can change what another's means. A line it refuses, or
# that is not one JSON object, is said by its number (the first 20, then
# how many more) and the others go in; each one that is a to-do is one of
# the day's writes. A request none of whose lines is one JSON object is a
# file of another shape (a pretty-printed one): it stops there, so that
# such a file costs one request whatever its size, and says the line to
# go on from. The desk's minute (too many changes in a minute) is waited
# out as it says, five times at most a request; the day's quota or a
# failure stops it, and says the line to go on from (--from N; the later
# write wins, so a line sent twice is "unchanged"). Exit 1 when every line
# was refused; a file sent again, all unchanged, is exit 0.
local file="${1:--}" added=0 changed=0 unchanged=0 refused=0 notjson=0 sent=0 say=20 bad="" part tries wait idx ln n nj total
case "${from:-1}" in ''|*[!0-9]*) die 2 "desk mem todo import --from N: a line number of the file (1 or more)" ;; esac
[ "${from:-1}" -ge 1 ] || die 2 "desk mem todo import --from N: a line number of the file (1 or more)"
if [ "$file" = - ]; then cat > "$TMP/todo.import"; file="$TMP/todo.import"; fi
[ -f "$file" ] || die 2 "no file $file"
# Each line to send with its number in the file before it (a tab
# between): blank lines and those before --from are left out.
LC_ALL=C awk -v from="${from:-1}" 'NR >= from && $0 ~ /[^ \t\r]/ { print NR "\t" $0 }' "$file" > "$TMP/todo.lines"
total=$(wc -l < "$TMP/todo.lines" | tr -d ' ')
[ "$total" -gt 0 ] || die 2 "desk mem todo import: no line to send in $file$([ "${from:-1}" = 1 ] || printf ' from line %s' "$from")"
rm -f "$TMP"/todo.part.*
LC_ALL=C awk -v pre="$TMP/todo.part." -v per=100 -v max=60000 '
{ b = length($0) + 1
if (n == per || (n > 0 && size + b > max)) { close(f); k++; n = 0; size = 0 }
f = sprintf("%s%06d", pre, k); print > f; n++; size += b }' "$TMP/todo.lines"
todo_stop() {
printf 'desk mem todo import: stopped at line %s; added %s, changed %s, unchanged %s, refused %s before it. Go on with --from %s%s\n' \
"$1" "$added" "$changed" "$unchanged" "$refused" "$1" "$2" >&2
mem_fail
}
# What came of it: the counts, and how many refused lines went unsaid.
todo_said() {
local more=""
if [ "$refused" -gt "$say" ]; then
more=" and $((refused - say)) more"
printf 'desk mem todo import: ... and %s more refused\n' "$((refused - say))" >&2
fi
if [ "$mode" = json ]; then
printf '{"lines":%s,"added":%s,"changed":%s,"unchanged":%s,"refused":%s}\n' "$total" "$added" "$changed" "$unchanged" "$refused"
else
printf 'added: %s, changed: %s, unchanged: %s, refused: %s%s\n' "$added" "$changed" "$unchanged" "$refused" "${bad:+ (lines$bad$more)}"
fi
}
for part in "$TMP"/todo.part.*; do
cut -f2- "$part" > "$TMP/req"
n=$(wc -l < "$part" | tr -d ' ')
sent=$((sent + n))
tries=0
while :; do
ACCEPT="application/json"
api POST "/api/desk/mem/todo" -H "$(desk_auth)" -H "Content-Type: application/x-ndjson" --data-binary "@$TMP/req"
# Too many a minute (not a day's quota, which says so): as long as
# the desk says, if that is two minutes or less.
if [ "$STATUS" = 429 ] && [ $tries -lt 5 ] && ! grep -q 'quota"' "$TMP/body"; then
wait="$(header_value retry-after)"; case "$wait" in ''|*[!0-9]*) wait=60 ;; esac
if [ "$wait" -le 120 ]; then
[ "$wait" -ge 1 ] || wait=1
note "too many changes in a minute; sending from line $(head -n 1 "$part" | cut -f1) again in ${wait}s"
tries=$((tries + 1)); sleep "$wait"; continue
fi
fi
break
done
ln="$(head -n 1 "$part" | cut -f1)"
case "$STATUS" in
2*)
added=$((added + $(json_get raw added 2>/dev/null || echo 0)))
changed=$((changed + $(json_get raw changed 2>/dev/null || echo 0)))
unchanged=$((unchanged + $(json_get raw unchanged 2>/dev/null || echo 0)))
# Those refused, by their index in this request.
for idx in $(grep -o '"index":[0-9]*,"error"' "$TMP/body" | sed 's/[^0-9]//g'); do
refused=$((refused + 1))
[ "$refused" -le "$say" ] || continue
ln="$(sed -n "$((idx + 1))p" "$part" | cut -f1)"
bad="$bad $ln"
printf 'desk mem todo import: line %s refused (%s)\n' "$ln" \
"$(grep -o "\"index\":$idx,\"error\":\"[^\"]*\"" "$TMP/body" | sed 's/.*"error":"\(.*\)"/\1/')" >&2
done
# No line of this request is one JSON object, and more are to come:
# a file of another shape. One request is enough to say so.
nj="$(json_get raw not_objects 2>/dev/null || echo 0)"
notjson=$((notjson + nj))
if [ "$nj" = "$n" ] && [ "$sent" -lt "$total" ]; then
ln="$(tail -n 1 "$part" | cut -f1)"
todo_said
die 1 "stopped at line $ln: none of the last $n lines is one JSON object, and a to-do file is one JSON object a line. To go on past them: --from $((ln + 1))"
fi ;;
# Only a line alone can pass what a request holds: the rest are cut under 60 KB.
413)
refused=$((refused + 1))
[ "$refused" -gt "$say" ] || { bad="$bad $ln"; printf 'desk mem todo import: line %s refused (it is past 64 KB)\n' "$ln" >&2; } ;;
429) todo_stop "$ln" "$(grep -q 'quota"' "$TMP/body" && printf ' after 00:00 UTC')" ;;
*) todo_stop "$ln" "" ;;
esac
done
todo_said
if [ $((added + changed + unchanged)) -eq 0 ]; then
[ "$notjson" -eq 0 ] || die 1 "every line was refused and nothing written: a to-do file is one JSON object a line"
die 1 "every line was refused and nothing written"
fi
;;
*) die 2 "unknown desk mem todo command: $sub (set, ls, close, rm, import; foldrender help desk-mem)" ;;
esac
}
# foldrender desk mem install [--agent claude|codex|all] [--remove]: writes
# the hooks that feed the memory and hand a new session what it holds.
# Claude Code and Codex get hook entries (Codex asks you to trust them once
# in its /hooks); the others (agy, grok, pi, opencode) have no hook here yet
# and use desk mem say / inject by hand. The desk agent token (frdk_) is
# kept in a 600 file the hook reads, and what was said goes to the hook on
# stdin and on to the Worker in a file: neither is ever on a command line.
cmd_desk_mem_install() {
local agents="" conf token="" remove=0
while [ $# -gt 0 ]; do
case "$1" in
--agent) need $# "$1"; agents="$agents,$2"; shift 2 ;;
--remove) remove=1; shift ;;
-*) die 2 "desk mem install: unknown option $1 (foldrender help desk-mem)" ;;
*) agents="$agents,$1"; shift ;;
esac
done
agents="${agents#,}"; agents="${agents:-claude,codex}"
case ",$agents," in *,all,*) agents="claude,codex" ;; esac
local a list=""
for a in $(printf '%s' "$agents" | tr ',' ' '); do
case "$a" in
claude|codex) list="$list $a" ;;
agy|gemini|grok|pi|opencode) note "$a: no hook yet; its sessions can still feed the memory with foldrender desk mem say and read it with foldrender desk mem inject" ;;
*) die 2 "desk mem install --agent claude|codex|all (not $a)" ;;
esac
done
command -v python3 >/dev/null 2>&1 || die 1 "desk mem install needs python3 (it edits the agents' hook files)"
conf="${FOLDRENDER_DESK_MEM_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/foldrender/desk-mem}"
if [ "$remove" = 1 ]; then
[ -f "$conf/install.py" ] || die 7 "nothing installed under $conf"
[ -z "$list" ] || FR_MEM_CONF="$conf" python3 "$conf/install.py" --remove $list
return 0
fi
[ -n "$list" ] || return 0
# The desk's agent token (frdk_, role agent: what the memory's API takes),
# made with your sign-in and kept in a 600 file. It is never printed: the
# hook reads it from the file into FOLDRENDER_DESK_TOKEN when it runs.
( umask 077 && mkdir -p "$conf" ) || die 1 "cannot make $conf"
if ! { [ -f "$conf/token" ] && head -n 1 "$conf/token" | grep -Eq '^frdk_[A-Za-z0-9_-]{20,200}$'; }; then
case "${FOLDRENDER_DESK_TOKEN:-}" in
frdk_*) token="$FOLDRENDER_DESK_TOKEN" ;;
*)
require_token
DESK_ID="${FOLDRENDER_DESK:-desk}"; check_slug "$DESK_ID" 2>/dev/null || DESK_ID=desk
printf '{"role":"agent","name":"mem-hook-%s"}' "$(default_machine_name | tr -cd 'A-Za-z0-9_.-' | cut -c1-40)" > "$TMP/req"
desk_send POST /tokens -H "$(auth_header)" -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || mem_fail
token="$(json_get str token 2>/dev/null)" ;;
esac
case "$token" in frdk_*) ;; *) die 1 "no desk agent token: run foldrender desk token add --role agent --name mem-hook and put the frdk_... it prints into $conf/token (chmod 600)" ;; esac
( umask 077 && printf '%s\n' "$token" > "$conf/token" ) || die 1 "cannot write $conf/token"
token=""
fi
# The hook: $1 is what happened (start|prompt|stop), $2 the agent; the
# agent's hook JSON comes on stdin.
cat > "$TMP/hook" <<'H'
#!/bin/sh
# foldrender desk mem hook, written by 'foldrender desk mem install'.
# $1 is what happened (start, prompt, stop), $2 the agent; the agent's hook
# JSON comes on stdin. The desk's agent token is read from the 600 file next
# to this one, and what was said goes on in a 600 file: neither is ever on a
# command line. It always exits 0 so the agent is never held up by it.
umask 077
conf="$(cd "$(dirname "$0")" && pwd)"
FOLDRENDER_DESK_TOKEN="$(head -n 1 "$conf/token" 2>/dev/null)"; export FOLDRENDER_DESK_TOKEN
# The command and the API it was installed with (an API only when one was named).
cli="${FOLDRENDER_CLI:-$(head -n 1 "$conf/cli" 2>/dev/null)}"; cli="${cli:-foldrender}"
if [ -z "${FOLDRENDER_API:-}" ] && [ -s "$conf/api" ]; then FOLDRENDER_API="$(head -n 1 "$conf/api")"; export FOLDRENDER_API; fi
log="$conf/hook.log"
t="$(umask 077 && mktemp "${TMPDIR:-/tmp}/fr-mem.XXXXXX")" || exit 0
cat > "$t"
case "$1" in
start)
# What a new session is handed, on stdout (the agent adds it to the context).
repo="$(python3 -I "$conf/event.py" repo "${2:-other}" < "$t" 2>>"$log")"
rm -f "$t"
"$cli" desk mem inject ${repo:+--repo "$repo"} 2>>"$log" || true ;;
prompt|stop)
if python3 -I "$conf/event.py" "$1" "${2:-other}" < "$t" > "$t.json" 2>>"$log"; then
# Sent in the background: a slow network never holds the agent up.
( "$cli" desk mem flush "$t.json" >/dev/null 2>>"$log"; rm -f "$t" "$t.json" ) /dev/null 2>&1 &
else rm -f "$t" "$t.json"; fi ;;
*) rm -f "$t" ;;
esac
exit 0
H
cat > "$TMP/event" <<'PY'
# foldrender desk mem: the agent's hook JSON (stdin) to the event the desk keeps.
import hashlib, json, os, socket, subprocess, sys
what, agent = sys.argv[1], (sys.argv[2] if len(sys.argv) > 2 else "other")
try:
d = json.load(sys.stdin)
except Exception:
d = {}
if not isinstance(d, dict):
d = {}
cwd = str(d.get("cwd") or os.getcwd())
# The repository's name: the folder that holds its .git, which a worktree
# shares with its main checkout; else the folder the session runs in.
repo = os.path.basename(cwd.rstrip("/"))
try:
g = subprocess.run(["git", "-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], capture_output=True, text=True, timeout=3)
common = g.stdout.strip()
if g.returncode == 0 and common:
top = os.path.dirname(common.rstrip("/")) if os.path.basename(common.rstrip("/")) == ".git" else common
repo = os.path.basename(top.rstrip("/")) or repo
except Exception:
pass
repo = repo[:128]
if what == "repo":
print(repo)
sys.exit(0)
def last_reply():
t = d.get("last_assistant_message")
if isinstance(t, str) and t.strip():
return t
p = d.get("transcript_path")
if not isinstance(p, str) or not os.path.isfile(p):
return ""
with open(p, "rb") as f:
f.seek(0, 2)
f.seek(max(0, f.tell() - 4 * 1024 * 1024))
lines = f.read().decode("utf-8", "replace").splitlines()
for line in reversed(lines):
try:
o = json.loads(line)
except Exception:
continue
m = o.get("message") if isinstance(o, dict) else None
if o.get("type") != "assistant" or not isinstance(m, dict):
continue
c = m.get("content")
if isinstance(c, str) and c.strip():
return c
if isinstance(c, list):
s = "\n".join(b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text").strip()
if s:
return s
return ""
text = (d.get("prompt") or "") if what == "prompt" else last_reply()
text = str(text).strip()[:4000]
if not text:
sys.exit(1)
sid = str(d.get("session_id") or "")
# 24 hex digits (96 bits): a longer run would be taken for a secret and refused.
ref = what[0] + "-" + hashlib.sha256((sid + "\0" + what + "\0" + text).encode("utf-8")).hexdigest()[:24]
ev = {"kind": what, "text": text, "agent": agent, "machine": socket.gethostname().split(".")[0][:64], "repo": repo, "ref": ref}
# The agent's own session, so two at once in one repository are two on the
# desk: "s-" and 16 hex digits of a hash of its id (a longer run would be
# taken for a secret and refused). None when the agent names no session.
if sid:
ev["sid"] = "s-" + hashlib.sha256((agent + "\0" + sid).encode("utf-8")).hexdigest()[:16]
print(json.dumps({"events": [ev]}, ensure_ascii=False))
PY
# A writer for the agents' hook files: it adds or removes our entries and
# leaves everything else in them as it was.
cat > "$TMP/install" <<'PY'
import json, os, sys
HOME = os.path.expanduser("~")
CONF = os.environ["FR_MEM_CONF"]
HOOK = os.path.join(CONF, "desk-mem-hook.sh")
FILES = {"claude": os.path.join(HOME, ".claude", "settings.json"), "codex": os.path.join(os.environ.get("CODEX_HOME") or os.path.join(HOME, ".codex"), "hooks.json")}
EVENTS = {"SessionStart": "start", "UserPromptSubmit": "prompt", "Stop": "stop"}
args = sys.argv[1:]
remove = "--remove" in args
def ours(h):
return isinstance(h, dict) and HOOK in str(h.get("command", ""))
for agent in [a for a in args if a in FILES]:
path = FILES[agent]
try:
with open(path) as f:
d = json.load(f)
except FileNotFoundError:
d = {}
except Exception as e:
print(f"{agent}: {path} is not JSON ({e}); left as it is", file=sys.stderr)
continue
hooks = d.setdefault("hooks", {})
for event, what in EVENTS.items():
groups = hooks.get(event) if isinstance(hooks.get(event), list) else []
for g in groups:
if isinstance(g, dict) and isinstance(g.get("hooks"), list):
g["hooks"] = [h for h in g["hooks"] if not ours(h)]
groups = [g for g in groups if not (isinstance(g, dict) and isinstance(g.get("hooks"), list) and not g["hooks"])]
if not remove:
groups.append({"hooks": [{"type": "command", "command": f'"{HOOK}" {what} {agent}', "timeout": 15}]})
if groups:
hooks[event] = groups
else:
hooks.pop(event, None)
if not hooks:
d.pop("hooks", None)
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".fr-mem.tmp"
with open(tmp, "w") as f:
json.dump(d, f, indent=2, ensure_ascii=False)
f.write("\n")
if os.path.exists(path):
os.chmod(tmp, os.stat(path).st_mode & 0o777)
os.replace(tmp, path)
print(f"{agent}: {'removed from' if remove else 'hooks in'} {path}")
PY
( umask 077 && cp "$TMP/hook" "$conf/desk-mem-hook.sh" && chmod 700 "$conf/desk-mem-hook.sh" && cp "$TMP/event" "$conf/event.py" && cp "$TMP/install" "$conf/install.py" \
&& cli_path > "$conf/cli" && if [ -n "${FOLDRENDER_API:-}" ]; then printf '%s\n' "$API" > "$conf/api"; else rm -f "$conf/api"; fi ) || die 1 "cannot write the hook under $conf"
FR_MEM_CONF="$conf" python3 "$conf/install.py" $list || die 1 "the agents' hook files could not be written"
case " $list " in *" codex "*) note "codex: open codex, run /hooks and trust the three foldrender desk mem hooks once; until then Codex does not run them" ;; esac
note "desk memory hooks are under $conf (token in $conf/token, 600). A new session is handed what foldrender desk mem inject --repo REPO prints; take them out with foldrender desk mem install --remove"
}
cmd_desk_machine() {
local op="${1:-ls}" name="" replace="" mode=text
[ $# -eq 0 ] || shift
case "$op" in
add)
while [ $# -gt 0 ]; do
case "$1" in
--replace) replace=1; shift ;;
--json) mode=json; shift ;;
-*) die 2 "desk machine add: unknown option $1" ;;
*) [ -z "$name" ] || die 2 "desk machine add takes one NAME"; name="$1"; shift ;;
esac
done
[ -n "$name" ] || die 2 "which machine? foldrender desk machine add NAME"
check_machine_name "$name"
machine_add "$name" "$replace"
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
json_get str token
printf '\n'
note "machine $name (id $(json_get str id 2>/dev/null || printf '?')): its token is shown once. Keep it in a 600 file or a secret store, never in a command line; on that machine: foldrender desk agent install --token-file FILE (or foldrender desk agent install there, signed in, which makes and keeps its own)"
;;
ls)
while [ $# -gt 0 ]; do case "$1" in --json) mode=json; shift ;; *) die 2 "desk machine ls [--json]" ;; esac; done
ACCEPT="application/json"
api_auth GET "/api/desk/machines"
ACCEPT="text/plain"
expect_2xx
[ "$mode" = json ] && { cat "$TMP/body"; printf '\n'; return 0; }
node_json_lines "$TMP/body" || cat "$TMP/body"
;;
rm)
[ $# -eq 1 ] || die 2 "desk machine rm ID (desk machine ls)"
case "$1" in ''|*[!0-9a-f]*) die 2 "a machine's id is 16 hex characters (desk machine ls)" ;; esac
[ "${#1}" -eq 16 ] || die 2 "a machine's id is 16 hex characters (desk machine ls)"
api_auth DELETE "/api/desk/machines/$1"
case "$STATUS" in
2??) printf 'revoked machine %s: its agent is disconnected, and what it reported is gone from the desk\n' "$1" ;;
404) die 7 "no machine $1 on this desk that is not revoked already (desk machine ls)" ;;
*) fail_http ;;
esac
;;
*) die 2 "desk machine add NAME | ls | rm ID (foldrender help desk)" ;;
esac
}
# The machines' list, a line each: ID NAME made … last heard … [revoked]
# (node reads the JSON; without node, the JSON is printed as it came).
node_json_lines() {
command -v node >/dev/null 2>&1 || return 1
node -e '
const b = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const ms = Array.isArray(b.machines) ? b.machines : [];
if (!ms.length) console.log("no machines yet: foldrender desk agent install (on the machine), or foldrender desk machine add NAME");
for (const m of ms) console.log([m.id, m.name, "made " + (m.created_at || "?"), "last heard " + (m.last_used_at || "never"), m.revoked_at ? "revoked " + m.revoked_at : ""].filter(Boolean).join(" "));
' "$1"
}
# The foldrender command itself, by its absolute path (what a service runs).
cli_path() {
local p="$0" d
case "$p" in */*) ;; *) p="$(command -v -- "$p" 2>/dev/null)" || p="$0" ;; esac
d="$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)" || die 1 "cannot tell where this foldrender command is"
printf '%s/%s' "$d" "$(basename "$p")"
}
# foldrender desk agent run|install|status|uninstall: the program that tells
# the desk what runs on this computer (herdr's agents and the machine's load),
# kept running by install. It is a Node program (foldrender-desk-agent.mjs)
# fetched from the site once, checked against FR_DESK_AGENT_SHA256 and kept in
# the cache folder, as the bridge is. Its token is the machine's (frdm_), in a
# 600 file; install makes one with your sign-in when there is none.
cmd_desk_agent() {
local op="${1:-}" node_major file cache url got cli want_token="" name="" replace="" a
[ $# -eq 0 ] || shift
case "$op" in
run|install|status|uninstall) ;;
'') die 2 "desk agent run | install | status | uninstall (foldrender help desk)" ;;
*) die 2 "unknown desk agent command: $op (run, install, status, uninstall)" ;;
esac
command -v node >/dev/null 2>&1 || die 2 "the desk agent needs Node 22 or later (https://nodejs.org); it is not installed"
node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null)"
case "$node_major" in ''|*[!0-9]*) node_major=0 ;; esac
[ "$node_major" -ge 22 ] || die 2 "the desk agent needs Node 22 or later; this is $(node -v 2>/dev/null)"
if [ -n "${FOLDRENDER_DESK_AGENT:-}" ]; then
file="$FOLDRENDER_DESK_AGENT"
[ -f "$file" ] || die 2 "FOLDRENDER_DESK_AGENT=$file is not a file"
else
cache="${XDG_CACHE_HOME:-$HOME/.cache}/foldrender"
file="$cache/desk-agent-$FR_DESK_AGENT_SHA256.mjs"
if [ ! -f "$file" ] || [ "$(sha256_of "$file")" != "$FR_DESK_AGENT_SHA256" ]; then
mkdir -p "$cache" || die 1 "cannot make $cache"
url="${API%/artifacts}/install/desk-agent/$FR_DESK_AGENT_SHA256.mjs"
curl -fsS --connect-timeout 15 --max-time 120 -o "$TMP/desk-agent.mjs" "$url" 2>"$TMP/err" || die 6 "cannot fetch the desk agent from $url ($(curl_error))"
got="$(sha256_of "$TMP/desk-agent.mjs")"
[ "$got" = "$FR_DESK_AGENT_SHA256" ] || die 1 "the desk agent at $url is not the one this foldrender ($FR_CLI_VERSION) runs; update foldrender: curl -fsSL ${API%/artifacts}/install.sh | sh"
mv "$TMP/desk-agent.mjs" "$file" || die 1 "cannot write $file"
fi
fi
cli="$(cli_path)"
if [ -n "${FOLDRENDER_API:-}" ]; then set -- --cli "$cli" --api "$API" "$@"; else set -- --cli "$cli" "$@"; fi
case "$op" in
run)
# The service's own process from here (signals reach it), and nothing
# of this run left behind: cron starts this every minute.
rm -rf "$TMP"
exec node "$file" run "$@"
;;
install)
for a in "$@"; do
case "$a" in
--token-file) want_token=given ;;
--replace) replace=1 ;;
esac
done
# --replace and --name are this command's; the program takes --name.
local args=() prev=""
for a in "$@"; do
if [ "$prev" = --name ]; then name="$a"; fi
prev="$a"
[ "$a" = --replace ] || args+=("$a")
done
if [ -z "$want_token" ] && { [ -n "$replace" ] || ! desk_agent_token_ok; }; then
# No token here yet (or --replace): one made now with your sign-in,
# handed to the program on its standard input, never printed.
require_token
[ -n "$name" ] || { name="$(default_machine_name)"; args+=(--name "$name"); }
check_machine_name "$name"
machine_add "$name" "$replace"
json_get str token > "$TMP/machine-token" || die 1 "the desk sent no token"
node "$file" install "${args[@]}" --token-file - < "$TMP/machine-token"
a=$?
rm -f "$TMP/machine-token"
return "$a"
fi
node "$file" install "${args[@]}"
;;
*) node "$file" "$op" "$@" ;;
esac
}
# Whether this computer has a machine token for the desk agent already.
desk_agent_token_ok() {
local f="${FOLDRENDER_DESK_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/foldrender/desk-agent}/token"
[ -f "$f" ] && head -n 1 "$f" | grep -Eq '^frdm_[A-Za-z0-9_-]{20,200}$'
}
# foldrender bridge --page PAGE [--agent NAME] [--dir DIR]: connects a coding
# agent on this computer to the page's agent sidebar, until stopped. The bridge
# is a Node program (foldrender-bridge.mjs) fetched from the site once, checked
# against FR_BRIDGE_SHA256 and kept in the cache folder; the sign-in goes to it
# in the environment, never on its command line.
# foldrender thread ...: the owner's messages on their desk (the second
# version's room), each the root of a tree on the page: what they said, then
# the assistant's replies, the sessions sent to work on it and what those
# reported. Written with the desk's agent token (FOLDRENDER_DESK_TOKEN) or the
# signed-in account, as the desk's cards are.
# Whether the answer in $TMP/body is a day's limit: this desk's, this account's
# share of every account's writes, or every account's. Asking again before 00:00
# UTC gets the same answer.
day_limit() {
grep -qE '"code" *: *"(quota|site_quota|account_quota)"|^code: *(quota|site_quota|account_quota)' "$TMP/body"
}
thread_fail() {
local msg
msg="$(error_text)"
case "$STATUS" in
401) die 3 "not signed in, or the token was rejected ($msg)" ;;
404) die 7 "${msg:-not found}: no such thread, or this account has no desk app (foldrender.dev/desk)" ;;
413) die 5 "too large: $msg" ;;
422|400) die 1 "refused ($msg)$(sed -n 's/.*"field" *: *"\([^"]*\)".*/, field \1/p' "$TMP/body" | head -n 1)" ;;
429) die 4 "$msg" ;;
*) fail_http ;;
esac
}
thread_key() {
case "$1" in
t-*) case "$1" in *[!A-Za-z0-9_.:-]*) die 2 "a thread's key is t- and letters, digits, . _ : - : $1" ;; esac ;;
*) die 2 "a thread's key starts with t- (foldrender thread ls lists them): $1" ;;
esac
}
# The text: an argument, or a file (- for stdin) with --text-file.
thread_text() {
if [ -n "$2" ]; then
if [ "$2" = - ]; then cat > "$TMP/ttext"; else [ -f "$2" ] || die 2 "no such file: $2"; cat "$2" > "$TMP/ttext"; fi
json_file "$TMP/ttext"
else
[ -n "$1" ] || die 2 "say what was said: a text argument, or --text-file FILE (- for stdin)"
json_arg "$1"
fi
}
thread_post() {
local path="$1" mode="$2"
ACCEPT="application/json"
api POST "/api/desk/threads$path" -H "$(desk_auth)" -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || thread_fail
[ "$mode" != json ] || { cat "$TMP/body"; printf '\n'; return 1; }
return 0
}
thread_hidden() {
local n
n="$(sed -n 's/.*"hidden" *: *\([0-9]*\).*/\1/p' "$TMP/body" | head -n 1)"
[ "${n:-0}" = 0 ] || note "$n piece(s) that looked like a secret were kept as [hidden]"
}
cmd_thread() {
case "${1:-}" in --help|-h|help) cmd_help thread; return 0 ;; esac
local sub="${1:-ls}" mode=text a key="" text="" tfile="" kind="" by="" via="" at="" ref="" rref="" into="" seq="" parent="" channel="" machine="" agent="" closed="" limit="" body
local adds=()
[ $# -eq 0 ] || shift
local rest=()
while [ $# -gt 0 ]; do
case "$1" in
--json) mode=json; shift ;;
--key) need $# "$1"; key="$2"; shift 2 ;;
--text-file) need $# "$1"; tfile="$2"; shift 2 ;;
--kind) need $# "$1"; kind="$2"; shift 2 ;;
--by) need $# "$1"; by="$2"; shift 2 ;;
--via) need $# "$1"; via="$2"; shift 2 ;;
--at) need $# "$1"; at="$(desk_when "$2")"; shift 2 ;;
--ref) need $# "$1"; ref="$2"; shift 2 ;;
--reply-to-ref) need $# "$1"; rref="$2"; shift 2 ;;
--add) need $# "$1"; adds+=("$2"); shift 2 ;;
--into) need $# "$1"; into="$2"; shift 2 ;;
--seq) need $# "$1"; seq="$2"; shift 2 ;;
--parent) need $# "$1"; parent="$2"; shift 2 ;;
--channel) need $# "$1"; channel="$2"; shift 2 ;;
--machine) need $# "$1"; machine="$2"; shift 2 ;;
--agent) need $# "$1"; agent="$2"; shift 2 ;;
--closed) closed=true; shift ;;
--open) closed=false; shift ;;
--limit) need $# "$1"; limit="$2"; shift 2 ;;
--help|-h) cmd_help thread; return 0 ;;
-) rest+=("$1"); shift ;;
-*) die 2 "thread $sub: unknown option $1 (foldrender help thread)" ;;
*) rest+=("$1"); shift ;;
esac
done
set -- ${rest[@]+"${rest[@]}"}
# Optional fields, as ,"name":value for each one given.
thread_opt() { [ -z "$2" ] || printf ',"%s":%s' "$1" "$(json_arg "$2")"; }
case "$sub" in
new)
[ -z "$key" ] || thread_key "$key"
[ -z "$parent" ] || thread_key "$parent"
body="$(thread_text "${1:-}" "$tfile")"
printf '{"text":%s%s%s%s%s%s%s%s}' "$body" "$(thread_opt key "$key")" "$(thread_opt at "$at")" "$(thread_opt by "$by")" "$(thread_opt parent "$parent")" "$(thread_opt channel "$channel")" "$(thread_opt ref "$ref")" "$(thread_opt parent_ref "$rref")" > "$TMP/req"
thread_post "" "$mode" || return 0
printf '%s\n' "$(sed -n 's/.*"key" *: *"\([^"]*\)".*/\1/p' "$TMP/body")"
grep -q '"result":"unchanged"' "$TMP/body" && note "this thread was there already (same key or ref): nothing written"
thread_hidden
;;
reply)
body="$(thread_text "${2:-${1:-}}" "$tfile")"
case "$kind" in ''|reply|progress|result|ask) ;; *) die 2 "--kind is reply, progress, result or ask" ;; esac
if [ -n "$rref" ]; then
printf '{"reply_to_ref":%s,"text":%s%s%s%s%s%s}' "$(json_arg "$rref")" "$body" "$(thread_opt kind "$kind")" "$(thread_opt by "$by")" "$(thread_opt via "$via")" "$(thread_opt at "$at")" "$(thread_opt ref "$ref")" > "$TMP/req"
thread_post "/reply" "$mode" || return 0
key="$(sed -n 's/.*"key" *: *"\([^"]*\)".*/\1/p' "$TMP/body")"
if grep -q '"result":"unchanged"' "$TMP/body"; then printf 'unchanged: %s already has this reply (--ref)\n' "$key"
else printf 'added reply %s to %s\n' "$(sed -n 's/.*"seq" *: *\([0-9]*\).*/\1/p' "$TMP/body")" "$key"; fi
thread_hidden
return 0
fi
[ $# -ge 1 ] || die 2 "thread reply KEY TEXT [--kind reply|progress|result|ask] [--via SESSION] [--by NAME] [--at WHEN] [--ref REF] (or: thread reply --reply-to-ref REF TEXT)"
thread_key "$1"; key="$1"; shift
body="$(thread_text "${1:-}" "$tfile")"
printf '{"text":%s%s%s%s%s%s}' "$body" "$(thread_opt kind "$kind")" "$(thread_opt by "$by")" "$(thread_opt via "$via")" "$(thread_opt at "$at")" "$(thread_opt ref "$ref")" > "$TMP/req"
thread_post "/$key/reply" "$mode" || return 0
a="$(sed -n 's/.*"key" *: *"\([^"]*\)".*/\1/p' "$TMP/body")"
if grep -q '"result":"unchanged"' "$TMP/body"; then printf 'unchanged: %s already has this reply (--ref)\n' "${a:-$key}"
else printf 'added reply %s to %s\n' "$(sed -n 's/.*"seq" *: *\([0-9]*\).*/\1/p' "$TMP/body")" "${a:-$key}"; fi
thread_hidden
;;
link)
[ $# -ge 2 ] || die 2 "thread link KEY SESSION [--machine M] [--agent A] [--parent SESSION] [--closed|--open]"
thread_key "$1"
printf '{"session":%s%s%s%s%s%s}' "$(json_arg "$2")" "$(thread_opt machine "$machine")" "$(thread_opt agent "$agent")" "$(thread_opt parent "$parent")" "$(thread_opt at "$at")" "${closed:+,\"closed\":$closed}" > "$TMP/req"
thread_post "/$1/link" "$mode" || return 0
a="$(sed -n 's/.*"parent" *: *"\([^"]*\)".*/\1/p' "$TMP/body")"
printf '%s %s under %s%s\n' "$(sed -n 's/.*"result" *: *"\([^"]*\)".*/\1/p' "$TMP/body")" "$2" "$1" "${a:+, for $a}"
;;
status)
[ $# -ge 2 ] || die 2 "thread status KEY received|doing|you|done"
thread_key "$1"
case "$2" in received|doing|you|done) ;; *) die 2 "a thread's state is received, doing, you or done" ;; esac
printf '{"state":"%s"}' "$2" > "$TMP/req"
thread_post "/$1/status" "$mode" || return 0
printf '%s is %s\n' "$1" "$2"
;;
ls)
case "${limit:-50}" in *[!0-9]*) die 2 "--limit is a number" ;; esac
ACCEPT="text/plain"
[ "$mode" != json ] || ACCEPT="application/json"
api GET "/api/desk/threads?limit=${limit:-50}" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || thread_fail
cat "$TMP/body"
[ "$mode" != json ] || printf '\n'
;;
import)
[ $# -ge 1 ] || die 2 "thread import FILE|- : lines of JSON (the assistant's threads.jsonl, or its record of the sessions it started)"
if [ "$1" = - ]; then cat > "$TMP/import"; else [ -f "$1" ] || die 2 "no such file: $1"; cat "$1" > "$TMP/import"; fi
# 100 lines a request (the desk takes 500 at most, and 2 MB).
rm -f "$TMP"/imp.*
split -l 100 "$TMP/import" "$TMP/imp."
local f tries total_t=0 total_r=0 total_l=0 total_s=0 total_u=0 total_k=0 total_h=0 total_n=0 n wait
local lines_all sent_lines=0 part_lines part_stopped
lines_all=$(awk 'END { print NR }' "$TMP/import")
thread_n() { local v; v="$(sed -n "s/.*\"$1\" *: *\\([0-9]*\\).*/\\1/p" "$TMP/body" | head -n 1)"; printf '%s' "${v:-0}"; }
thread_added() {
total_t=$((total_t + $(thread_n threads))); total_r=$((total_r + $(thread_n replies))); total_l=$((total_l + $(thread_n links)))
total_s=$((total_s + $(thread_n sessions))); total_u=$((total_u + $(thread_n unchanged))); total_k=$((total_k + $(thread_n skipped)))
total_h=$((total_h + $(thread_n hidden))); total_n=$((total_n + $(thread_n no_thread)))
}
thread_said() {
printf 'imported %s threads, %s replies, %s links, %s sessions; %s unchanged, %s skipped, %s with no thread\n' "$total_t" "$total_r" "$total_l" "$total_s" "$total_u" "$total_k" "$total_n"
}
for f in "$TMP"/imp.*; do
[ -s "$f" ] || continue
tries=0
while :; do
ACCEPT="application/json"
api POST "/api/desk/threads/import" -H "$(desk_auth)" -H "Content-Type: application/x-ndjson" --data-binary "@$f"
# Too many a minute: as long as the desk says, three times at most. A
# day's limit, or a wait in hours, is not waited for.
if [ "$STATUS" = 429 ] && [ $tries -lt 3 ] && ! day_limit; then
wait="$(header_value retry-after)"; case "$wait" in ''|*[!0-9]*) wait=20 ;; esac
[ "$wait" -le 120 ] || break
tries=$((tries + 1)); sleep "$wait"; continue
fi
break
done
part_lines=$(awk 'END { print NR }' "$f")
# A day's limit: what went is kept, and the same command tomorrow goes on
# (a line already there is not written twice). The
# desk says how many of this part's lines did not go; none said, none went.
if [ "$STATUS" = 429 ] && day_limit; then
thread_added
part_stopped="$(thread_n stopped)"
[ "$part_stopped" -gt 0 ] || part_stopped="$part_lines"
n=$((lines_all - sent_lines - part_lines + part_stopped))
if [ "$mode" = json ]; then
cat "$TMP/body"; printf '\n'
printf '{"stopped":true,"lines":%s,"to_go":%s}\n' "$lines_all" "$n"
else
thread_said
fi
printf 'thread import: up to %s of %s lines are still to go. Run the same command after 00:00 UTC to go on: what is already in is not written again.\n' "$n" "$lines_all" >&2
thread_fail
fi
[ "${STATUS#2}" != "$STATUS" ] || thread_fail
sent_lines=$((sent_lines + part_lines))
[ "$mode" != json ] || { cat "$TMP/body"; printf '\n'; continue; }
thread_added
grep -o '"line":[0-9]*,"why":"[^"]*"' "$TMP/body" | sed 's/"line":\([0-9]*\),"why":"\(.*\)"/skipped a line (\1 of its part): \2/' >&2
done
[ "$mode" != json ] || return 0
thread_said
[ "$total_h" = 0 ] || note "$total_h piece(s) that looked like a secret were kept as [hidden]"
;;
ref)
[ $# -ge 1 ] || die 2 "thread ref KEY[#seq] --add REF... : the chat ids a message came to have"
case "$1" in *#*) key="${1%%#*}"; seq="${1##*#}" ;; *) key="$1" ;; esac
thread_key "$key"
case "${seq:-0}" in ''|*[!0-9]*) die 2 "#seq is a reply's number" ;; esac
[ ${#adds[@]} -gt 0 ] || die 2 "thread ref $1 --add REF..."
a=""; for r2 in "${adds[@]}"; do a="${a}${a:+,}$(json_arg "$r2")"; done
printf '{"add":[%s]%s}' "$a" "${seq:+,\"seq\":$seq}" > "$TMP/req"
thread_post "/$key/ref" "$mode" || return 0
grep -o '"added":\[[^]]*\]' "$TMP/body" | sed 's/"added":\[\(.*\)\]/kept: \1/;s/","/", "/g;s/"//g'
;;
find)
[ -n "$ref" ] || die 2 "thread find --ref REF : which message a chat id became"
ACCEPT="application/json"
api GET "/api/desk/threads/find?ref=$(urlencode "$ref")" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || thread_fail
[ "$mode" != json ] || { cat "$TMP/body"; printf '\n'; return 0; }
sed -n 's/.*"address" *: *"\([^"]*\)".*/\1/p' "$TMP/body"
;;
move)
[ $# -ge 1 ] && [ -n "$into" ] || die 2 "thread move KEY --into KEY : a thread opened by mistake goes into another"
thread_key "$1"; thread_key "$into"
printf '{"into":%s}' "$(json_arg "$into")" > "$TMP/req"
thread_post "/$1/move" "$mode" || return 0
printf '%s went into %s\n' "$1" "$into"
;;
show)
[ $# -ge 1 ] || die 2 "thread show KEY"
thread_key "$1"
ACCEPT="application/json"
api GET "/api/desk/threads/$1" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || thread_fail
[ "$mode" != json ] || { cat "$TMP/body"; printf '\n'; return 0; }
thread_show "$TMP/body"
;;
hook) cmd_thread_hook "$@" ;;
*) die 2 "thread new | reply | link | status | ls | show | ref | find | move | import | hook (foldrender help thread)" ;;
esac
}
# ------------------------------------------------------------------ pack
# A context pack: what an agent picked out of this account's threads, pages
# and files, kept as it was, to hand to another session. A file or a text is
# checked here before anything is sent, so that a secret in it never leaves
# this machine: the server's patterns that a short grep can hold (src/desk.js
# SECRET_RES); the server checks again, long random runs too.
PACK_ITEM_MAX=16384
# The same prefixes as the server's (src/desk.js SECRET_RES), so that what the
# server would refuse is never sent to it. A prefix added there goes here too.
PACK_SECRET_RE='(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{8,}|github_pat_|gh[ousr]_[A-Za-z0-9]{8,}|frat_[A-Za-z0-9]|frdk_[A-Za-z0-9]|AKIA[0-9A-Z]{12,}|xox[abprs]-[A-Za-z0-9-]*|tk_[0-9A-Z]{20,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}|[?&]k=[A-Za-z0-9_-]{6,}|glpat-[A-Za-z0-9_-]{8,}|AIza[A-Za-z0-9_-]{8,}|sk_live_[A-Za-z0-9]|hf_[A-Za-z0-9]{8,}|npm_[A-Za-z0-9]{8,}|SG\.[A-Za-z0-9_-]{8,}|frdm_[A-Za-z0-9_-]{8,})'
pack_ref() {
case "$1" in
p-*) case "$1" in p-[a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9]) return 0 ;; esac ;;
esac
case "$1" in ''|*[!A-Za-z0-9_.:-]*|[!A-Za-z0-9]*) die 2 "a pack is its id (p-…) or its name (foldrender pack ls lists them): $1" ;; esac
}
# pack_secret FILE LABEL: refuse FILE, before anything is sent, if a line of
# it looks like a secret (which line, never what is on it); with --redact,
# the copy sent has each kept as [hidden].
pack_secret() {
local line
line="$(grep -nE -- "$PACK_SECRET_RE|-----BEGIN [A-Z ]*KEY" "$1" | head -n 1 | cut -d: -f1)"
[ -n "$line" ] || return 0
[ -n "$PACK_REDACT" ] || die 2 "$2: line $line looks like it holds a secret (a key, a token, a private key, a link's ?k=); nothing was sent. Leave it out, or pass --redact to keep each as [hidden]"
sed -E "/-----BEGIN [A-Z ]*KEY/,/-----END [A-Z ]*KEY/ s/.*/[hidden]/; s/$PACK_SECRET_RE/[hidden]/g" "$1" > "$1.red" && mv -f "$1.red" "$1" || die 1 "cannot write $1"
}
pack_field() {
sed -n "s/.*\"$1\" *: *\"\{0,1\}\([^\",}]*\).*/\1/p; s/^$1: *//p" "$TMP/body" | head -n 1
}
pack_fail() {
local msg item line
msg="$(error_text)"
case "$STATUS" in
401) die 3 "not signed in, or the token was rejected ($msg). Run: foldrender login" ;;
403) die 3 "$msg" ;;
404) die 7 "${msg:-not found}" ;;
413) die 5 "too large: $msg" ;;
422)
item="$(pack_field item)"; line="$(pack_field line)"
if grep -q looks_like_secret "$TMP/body"; then
case "${item:-}" in 0|'') die 1 "refused: the $(pack_field field) looks like a secret; nothing was kept" ;; esac
die 1 "refused: item $item${line:+, line $line} looks like it holds a secret; nothing was kept. Leave it out, or pass --redact to keep each as [hidden]"
fi
die 1 "refused${item:+ (item $item)}: $msg" ;;
400) die 1 "refused ($msg)$(pack_field field | sed 's/^./, field &/')" ;;
429) die 4 "$msg" ;;
409|503) die 1 "$msg" ;;
*) fail_http ;;
esac
}
cmd_pack() {
case "${1:-}" in --help|-h|help) cmd_help pack; return 0 ;; esac
local sub="${1:-ls}" mode=text goal="" name="" dry="" i=0 kind spec key range from to f size sha req q local_q n p s ref
[ $# -eq 0 ] || shift
PACK_REDACT=""
local P_KIND=() P_ARG=() P_LABEL=() rest=()
while [ $# -gt 0 ]; do
case "$1" in
--json) mode=json; shift ;;
--goal) need $# "$1"; goal="$2"; shift 2 ;;
--name) need $# "$1"; name="$2"; shift 2 ;;
--dry-run) dry=1; shift ;;
--redact) PACK_REDACT=1; shift ;;
--thread|--page|--file|--text|--text-file)
need $# "$1"; P_KIND+=("${1#--}"); P_ARG+=("$2"); P_LABEL+=(""); shift 2 ;;
--label)
need $# "$1"
n=${#P_KIND[@]}
case "${P_KIND[$((n - 1))]:-}" in text|text-file) P_LABEL[$((n - 1))]="$2" ;; *) die 2 "--label names the --text (or --text-file) just before it" ;; esac
shift 2 ;;
--help|-h) cmd_help pack; return 0 ;;
-*) die 2 "pack $sub: unknown option $1 (foldrender help pack)" ;;
*) rest+=("$1"); shift ;;
esac
done
set -- ${rest[@]+"${rest[@]}"}
case "$sub" in
new)
[ $# -eq 0 ] || die 2 "pack new takes its items as options: --thread, --page, --file, --text (foldrender help pack)"
[ ${#P_KIND[@]} -gt 0 ] || die 2 "a pack holds at least one item: --thread t-...[#N-M], --page PAGE, --file FILE, --text TEXT"
[ ${#P_KIND[@]} -le 20 ] || die 2 "a pack holds at most 20 items"
[ -z "$name" ] || pack_ref "$name"
case "$name" in p-*) die 2 "--name is not a pack's id (p-…): choose another" ;; esac
# Everything is read and checked here first: nothing is sent until every item is.
: > "$TMP/items"
while [ $i -lt ${#P_KIND[@]} ]; do
kind="${P_KIND[$i]}"; spec="${P_ARG[$i]}"
[ $i -eq 0 ] || printf ',' >> "$TMP/items"
case "$kind" in
thread)
key="${spec%%#*}"; from=""; to=""
thread_key "$key"
if [ "$key" != "$spec" ]; then
range="${spec#*#}"
from="${range%%-*}"
case "$range" in *-*) to="${range#*-}" ;; *) to="$from" ;; esac
case "$from" in ''|*[!0-9]*) die 2 "--thread $spec: after # comes a message's number, or N-M, or N- (from N on)" ;; esac
case "$to" in *[!0-9]*) die 2 "--thread $spec: after # comes a message's number, or N-M, or N- (from N on)" ;; esac
[ "$from" -ge 1 ] || die 2 "--thread $spec: messages are numbered from 1"
[ -z "$to" ] || [ "$to" -ge "$from" ] || die 2 "--thread $spec: $from comes after $to"
fi
printf '{"thread":%s%s%s}' "$(json_arg "$key")" "${from:+,\"from\":$from}" "${to:+,\"to\":$to}" >> "$TMP/items" ;;
page)
page_id "$spec"
check_slug "$PAGE_ID"
printf '{"page":%s}' "$(json_arg "$PAGE_ID")" >> "$TMP/items" ;;
file)
[ -f "$spec" ] || die 2 "no such file: $spec"
[ -r "$spec" ] || die 2 "cannot read: $spec"
case "$spec" in *$'\t'*|*$'\n'*) die 2 "--file $spec: a path with a tab or a line break in it cannot be named in a pack" ;; esac
size="$(wc -c < "$spec" | tr -d ' ')"
sha="$(sha256_of "$spec")"
if [ "$size" -gt "$PACK_ITEM_MAX" ]; then
# Too large to keep in a pack: where it is, what it was, and nothing of what it says.
printf '{"file":%s,"sha256":"%s","bytes":%s}' "$(json_arg "$spec")" "$sha" "$size" >> "$TMP/items"
else
[ "$(tr -d '\000' < "$spec" | wc -c | tr -d ' ')" = "$size" ] || die 2 "--file $spec holds NUL bytes, so it is not text"
cat "$spec" > "$TMP/pf.$i"
pack_secret "$TMP/pf.$i" "--file $spec"
printf '{"file":%s,"sha256":"%s","text":%s}' "$(json_arg "$spec")" "$sha" "$(json_file "$TMP/pf.$i")" >> "$TMP/items"
fi ;;
text|text-file)
if [ "$kind" = text ]; then printf '%s' "$spec" > "$TMP/pf.$i"
elif [ "$spec" = - ]; then cat > "$TMP/pf.$i"
else [ -f "$spec" ] || die 2 "no such file: $spec"; cat "$spec" > "$TMP/pf.$i"; fi
[ -s "$TMP/pf.$i" ] || die 2 "--$kind: there is no text"
[ "$(wc -c < "$TMP/pf.$i" | tr -d ' ')" -le "$PACK_ITEM_MAX" ] || die 5 "--$kind: a text item is at most 16 KB; give a longer one as --file"
pack_secret "$TMP/pf.$i" "--$kind"
printf '{"text":%s%s}' "$(json_file "$TMP/pf.$i")" "${P_LABEL[$i]:+,\"label\":$(json_arg "${P_LABEL[$i]}")}" >> "$TMP/items" ;;
esac
i=$((i + 1))
done
if [ -n "$goal" ]; then
printf '%s' "$goal" > "$TMP/pgoal"
pack_secret "$TMP/pgoal" "--goal"
goal="$(cat "$TMP/pgoal")"
fi
{ printf '{"items":['; cat "$TMP/items"; printf ']'
[ -z "$goal" ] || printf ',"goal":%s' "$(json_arg "$goal")"
[ -z "$name" ] || printf ',"name":%s' "$(json_arg "$name")"
[ -z "$PACK_REDACT" ] || printf ',"redact":true'
printf '}'; } > "$TMP/req"
ACCEPT="application/json"
[ -z "$dry" ] || ACCEPT="text/plain"
api POST "/api/packs${dry:+?dry_run=1}" -H "$(desk_auth)" -H "Content-Type: application/json" --data-binary "@$TMP/req"
[ "${STATUS#2}" != "$STATUS" ] || pack_fail
if [ -n "$dry" ] || [ "$mode" = json ]; then cat "$TMP/body"; [ -n "$dry" ] || printf '\n'; return 0; fi
p="$(pack_field id)"
printf '%s\n' "$p"
n="$(pack_field pointers)"
if [ "$(pack_field result)" = unchanged ]; then
note "unchanged: the pack named $name holds these items already"
else
s="$(pack_field replaces)"
note "made pack $p: $(pack_field items) item(s), about $(pack_field tokens) tokens${s:+; it replaces $s under the name $name}"
fi
[ "${n:-0}" = 0 ] || note "$n item(s) kept only as where to find them (too large); the pack says where"
[ "$(pack_field hidden)" = 0 ] || note "$(pack_field hidden) piece(s) that looked like a secret were kept as [hidden]"
note "read it, or hand it to another session: foldrender pack show $p"
;;
show)
[ $# -eq 1 ] || die 2 "pack show ID|NAME [--json]"
pack_ref "$1"; ref="$1"
if [ "$mode" = json ]; then
ACCEPT="application/json"
api GET "/api/packs/$ref?format=json" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || pack_fail
cat "$TMP/body"; printf '\n'
return 0
fi
# Its files, compared with this machine's at the same path: the server
# is told only same, changed or missing.
ACCEPT="text/plain"
api GET "/api/packs/$ref/files" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || pack_fail
local_q=""
while IFS="$(printf '\t')" read -r n s f; do
[ -n "$n" ] || continue
if [ -f "$f" ]; then
if [ "$(sha256_of "$f")" = "$s" ]; then q=same; else q=changed; fi
else q=missing; fi
local_q="${local_q:+$local_q,}$n:$q"
done < "$TMP/body"
api GET "/api/packs/$ref${local_q:+?local=$local_q}" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || pack_fail
cat "$TMP/body"
;;
ls)
[ $# -eq 0 ] || die 2 "pack ls [--json]"
ACCEPT="text/plain"
[ "$mode" != json ] || ACCEPT="application/json"
api GET "/api/packs" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || pack_fail
cat "$TMP/body"
[ "$mode" != json ] || printf '\n'
;;
rm)
[ $# -ge 1 ] || die 2 "pack rm ID|NAME..."
for ref in "$@"; do pack_ref "$ref"; done
for ref in "$@"; do
ACCEPT="application/json"
api DELETE "/api/packs/$ref" -H "$(desk_auth)"
[ "${STATUS#2}" != "$STATUS" ] || pack_fail
printf 'removed %s\n' "$(pack_field id)"
done
;;
*) die 2 "pack new | show | ls | rm (foldrender help pack)" ;;
esac
}
# One thread as text (thread show): the words, then each session and reply.
# With Node to read the JSON; else the answer as it came.
thread_show() {
if command -v node >/dev/null 2>&1; then
node -e '
const t = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const cut = (s, n) => { s = String(s || "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
console.log(`[${t.key}] ${t.state} · ${t.at}${t.by ? ` · ${t.by}` : ""}${t.parent ? ` · follows ${t.parent}` : ""}${t.moved ? ` · moved into this one from ${t.moved}` : ""}`);
console.log(t.text);
for (const s of t.sessions || []) console.log(` session ${s.name}${s.machine ? ` on ${s.machine}` : ""}${s.parent ? ` (for ${s.parent})` : ""}${s.closed ? " closed" : ""}`);
for (const e of t.events || []) console.log(` #${e.seq} ${e.at} ${e.via ? `${e.via} reported` : e.by || "assistant"} (${e.kind}): ${cut(e.text, 200)}`);
if (t.more) console.log(` … and ${t.more} older replies`);
' "$1" || cat "$1"
else
cat "$1"
fi
}
# foldrender thread hook claude|codex prompt|stop: what an agent's hooks run
# (UserPromptSubmit, Stop). prompt: the user's words become a thread (or a
# report from a session a thread was handed to is added to it), and the agent
# is told the address; stop: the turn's last reply goes under it. Never in the
# agent's way: anything that fails is silent (exit 0, nothing on stderr), and
# each call waits 6 seconds at most. FOLDRENDER_THREAD_AUTHOR names the user,
# FOLDRENDER_THREAD_CHANNEL where the words came from,
# FOLDRENDER_THREAD_SESSION the session's own name (bound to the thread),
# FOLDRENDER_THREAD_SKIP a file of prompt beginnings to leave out (one a
# line: a scheduled prompt's), FOLDRENDER_THREAD_AGENT the reply's author.
cmd_thread_hook() {
local agent="${1:-}" event="${2:-}" sid state addr body
case "$agent" in claude|codex) ;; install) shift; thread_hook_install "$@"; return ;; *) die 2 "thread hook claude|codex prompt|stop (or: thread hook install claude|codex)" ;; esac
cat > "$TMP/in" 2>/dev/null || exit 0
sid="$(json_get str session_id "$TMP/in" 2>/dev/null)"
case "$sid" in ''|*[!A-Za-z0-9_.:-]*) exit 0 ;; esac
state="$CONFIG_DIR/threads/$agent-$sid"
mkdir -p "$CONFIG_DIR/threads" 2>/dev/null || exit 0
[ -n "$TOKEN" ] || [ -n "${FOLDRENDER_DESK_TOKEN:-}" ] || exit 0
case "$event" in
prompt)
rm -f "$state"
json_get str prompt "$TMP/in" > "$TMP/text" 2>/dev/null || exit 0
[ -s "$TMP/text" ] || exit 0
printf '{"event":"prompt","prompt":%s,"author":%s,"channel":%s,"session":%s' "$(json_file "$TMP/text")" "$(json_arg "${FOLDRENDER_THREAD_AUTHOR:-owner}")" "$(json_arg "${FOLDRENDER_THREAD_CHANNEL:-$agent}")" "$(json_arg "${FOLDRENDER_THREAD_SESSION:-}")" > "$TMP/req.json"
if [ -f "${FOLDRENDER_THREAD_SKIP:-}" ]; then
printf ',"skip":[' >> "$TMP/req.json"
head -n 20 "$FOLDRENDER_THREAD_SKIP" | awk 'NF' > "$TMP/skip"
local first=1 line
while IFS= read -r line; do printf '%s' "$line" > "$TMP/one"; [ $first = 1 ] || printf ',' >> "$TMP/req.json"; first=0; json_str "$TMP/one" >> "$TMP/req.json"; done < "$TMP/skip"
printf ']' >> "$TMP/req.json"
fi
printf '}' >> "$TMP/req.json"
api_once POST "/api/desk/threads/hook" -H "$(desk_auth)" -H "Content-Type: application/json" --data-binary "@$TMP/req.json" --max-time 6 || exit 0
[ "${STATUS#2}" != "$STATUS" ] || exit 0
addr="$(json_get str address "$TMP/body" 2>/dev/null)"
[ -n "$addr" ] || exit 0
printf '%s\n' "$addr" > "$state"
json_get str context "$TMP/body" > "$TMP/ctx" 2>/dev/null || exit 0
[ -s "$TMP/ctx" ] || exit 0
printf '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":%s}}\n' "$(json_file "$TMP/ctx")"
;;
stop)
[ -f "$state" ] || exit 0
[ "$(json_get raw stop_hook_active "$TMP/in" 2>/dev/null)" != true ] || exit 0
addr="$(head -n 1 "$state")"
rm -f "$state"
json_get str last_assistant_message "$TMP/in" > "$TMP/text" 2>/dev/null || exit 0
[ -s "$TMP/text" ] || exit 0
body="$(json_file "$TMP/text")"
printf '{"event":"reply","address":%s,"text":%s,"author":%s,"ref":%s}' "$(json_arg "$addr")" "$body" "$(json_arg "${FOLDRENDER_THREAD_AGENT:-$agent}")" "$(json_arg "hook-$(printf '%s %s' "$addr" "$body" | cksum | tr ' ' '-')")" > "$TMP/req.json"
api_once POST "/api/desk/threads/hook" -H "$(desk_auth)" -H "Content-Type: application/json" --data-binary "@$TMP/req.json" --max-time 6 || exit 0
;;
*) die 2 "thread hook $agent prompt|stop" ;;
esac
exit 0
}
# foldrender thread hook install claude|codex [--author NAME] [--channel C]
# [--skip-file F]: the two hooks into the agent's settings (Claude Code
# ~/.claude/settings.json, Codex ~/.codex/hooks.json), kept beside the hooks
# already there; with Node to merge them in, else printed for you to add.
# Nothing else in the file changes.
thread_hook_install() {
local agent="${1:-}" file self env="" a
[ -n "$agent" ] && shift || die 2 "thread hook install claude|codex [--author NAME] [--channel C] [--skip-file FILE]"
while [ $# -gt 0 ]; do
case "$1" in
--author) need $# "$1"; env="${env}FOLDRENDER_THREAD_AUTHOR='$(printf '%s' "$2" | sed "s/'/'\\\\''/g")' "; shift 2 ;;
--channel) need $# "$1"; env="${env}FOLDRENDER_THREAD_CHANNEL='$(printf '%s' "$2" | sed "s/'/'\\\\''/g")' "; shift 2 ;;
--session) need $# "$1"; env="${env}FOLDRENDER_THREAD_SESSION='$(printf '%s' "$2" | sed "s/'/'\\\\''/g")' "; shift 2 ;;
--skip-file) need $# "$1"; a="$(cd "$(dirname "$2")" 2>/dev/null && pwd)/$(basename "$2")"; env="${env}FOLDRENDER_THREAD_SKIP='$a' "; shift 2 ;;
*) die 2 "thread hook install $agent: unknown option $1" ;;
esac
done
case "$agent" in
claude) file="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" ;;
codex) file="${CODEX_HOME:-$HOME/.codex}/hooks.json" ;;
*) die 2 "thread hook install claude|codex" ;;
esac
self="$(command -v foldrender 2>/dev/null || printf '%s' "$0")"
local p="${env}'$self' thread hook $agent prompt" s="${env}'$self' thread hook $agent stop"
if command -v node >/dev/null 2>&1; then
mkdir -p "$(dirname "$file")" || die 1 "cannot make $(dirname "$file")"
node -e '
const fs = require("fs");
const [file, p, s] = process.argv.slice(1);
let j = {};
if (fs.existsSync(file)) { j = JSON.parse(fs.readFileSync(file, "utf8") || "{}"); fs.copyFileSync(file, file + ".bak-foldrender"); }
j.hooks = j.hooks || {};
for (const [ev, cmd] of [["UserPromptSubmit", p], ["Stop", s]]) {
const list = (j.hooks[ev] = j.hooks[ev] || []);
const mine = / thread hook (claude|codex) (prompt|stop)"?$/;
for (const g of list) g.hooks = (g.hooks || []).filter((h) => !(h && typeof h.command === "string" && mine.test(h.command)));
list.push({ hooks: [{ type: "command", command: cmd, timeout: 10 }] });
}
for (const ev of ["UserPromptSubmit", "Stop"]) j.hooks[ev] = j.hooks[ev].filter((g) => (g.hooks || []).length);
fs.writeFileSync(file, JSON.stringify(j, null, 2) + "\n");
' "$file" "$p" "$s" || die 1 "could not write the hooks into $file (it is kept as it was in $file.bak-foldrender)"
note "added the message hooks to $file (UserPromptSubmit and Stop; the file before is $file.bak-foldrender). Each new session of $agent now writes the user's messages and its replies to the desk."
else
note "Node is not here to merge them in: add these two hooks to $file (the user's words become threads, the reply goes under them):"
printf '{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":%s,"timeout":10}]}],"Stop":[{"hooks":[{"type":"command","command":%s,"timeout":10}]}]}}\n' "$(json_arg "$p")" "$(json_arg "$s")"
fi
}
cmd_bridge() {
local target="" agent="claude" dir="$PWD" session="" channel="" mesh_cli="" mcp="" node_major file cache url got
while [ $# -gt 0 ]; do
case "$1" in
--page) need $# "$1"; target="$2"; shift 2 ;;
--agent) need $# "$1"; agent="$2"; shift 2 ;;
--session) need $# "$1"; session="$2"; shift 2 ;;
--mesh-cli) need $# "$1"; mesh_cli="$2"; shift 2 ;;
--mcp) need $# "$1"; mcp="$2"; shift 2 ;;
--channel) channel=1; shift ;;
--dir) need $# "$1"; dir="$2"; shift 2 ;;
-*) die 2 "unknown option: $1" ;;
*) [ -z "$target" ] || die 2 "bridge takes one page"; target="$1"; shift ;;
esac
done
[ -n "$target" ] || die 2 "which page? foldrender bridge --page PAGE"
case "$agent" in claude|codex|gemini|antigravity|grok|devin|pi) ;; *) die 2 "unknown agent: $agent (one of: claude, codex, gemini, antigravity, grok, devin, pi)" ;; esac
[ -z "$mesh_cli" ] || [ -n "$session" ] || die 2 "--mesh-cli goes with --session NAME (the mesh's CLI that session's messages are sent with)"
[ -d "$dir" ] || die 2 "--dir $dir is not a folder"
[ -z "$mcp" ] || [ -z "$session$channel" ] || die 2 "--mcp is for an agent the bridge starts (--agent), not a terminal session"
[ -z "$mcp" ] || [ -f "$mcp" ] || die 2 "--mcp $mcp is not a file"
require_token
command -v node >/dev/null 2>&1 || die 2 "the bridge needs Node 22 or later (https://nodejs.org); it is not installed"
node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null)"
case "$node_major" in ''|*[!0-9]*) node_major=0 ;; esac
[ "$node_major" -ge 22 ] || die 2 "the bridge needs Node 22 or later; this is $(node -v 2>/dev/null)"
page_id "$target"
if [ -n "${FOLDRENDER_BRIDGE:-}" ]; then
file="$FOLDRENDER_BRIDGE"
[ -f "$file" ] || die 2 "FOLDRENDER_BRIDGE=$file is not a file"
else
cache="${XDG_CACHE_HOME:-$HOME/.cache}/foldrender"
file="$cache/bridge-$FR_BRIDGE_SHA256.mjs"
if [ ! -f "$file" ] || [ "$(sha256_of "$file")" != "$FR_BRIDGE_SHA256" ]; then
mkdir -p "$cache" || die 1 "cannot make $cache"
# By its sha256: every released bridge stays on the site under its own
# name, so an older foldrender keeps the bridge it was made with.
url="${API%/artifacts}/install/bridge/$FR_BRIDGE_SHA256.mjs"
curl -fsS --connect-timeout 15 --max-time 120 -o "$TMP/bridge.mjs" "$url" 2>"$TMP/err" || die 6 "cannot fetch the bridge from $url ($(curl_error))"
got="$(sha256_of "$TMP/bridge.mjs")"
[ "$got" = "$FR_BRIDGE_SHA256" ] || die 1 "the bridge at $url is not the one this foldrender ($FR_CLI_VERSION) runs; update foldrender: curl -fsSL ${API%/artifacts}/install.sh | sh"
mv "$TMP/bridge.mjs" "$file" || die 1 "cannot write $file"
fi
fi
export FOLDRENDER_TOKEN="$TOKEN"
if [ -n "$channel" ]; then
exec node "$file" --api "$API" --page "$PAGE_ID" --channel --dir "$dir"
fi
if [ -n "$session" ]; then
if [ -n "$mesh_cli" ]; then
exec node "$file" --api "$API" --page "$PAGE_ID" --session "$session" --mesh-cli "$mesh_cli" --dir "$dir"
fi
exec node "$file" --api "$API" --page "$PAGE_ID" --session "$session" --dir "$dir"
fi
if [ -n "$mcp" ]; then
exec node "$file" --api "$API" --page "$PAGE_ID" --agent "$agent" --mcp "$mcp" --dir "$dir"
fi
exec node "$file" --api "$API" --page "$PAGE_ID" --agent "$agent" --dir "$dir"
}
# foldrender reply --page PAGE [--to N] TEXT|-|--file FILE: a terminal session's answer to
# a message in PAGE's agent sidebar (the bridge, --session, said how).
cmd_reply() {
local target="" to="" text="" got_text="" file=""
while [ $# -gt 0 ]; do
case "$1" in
--page) need $# "$1"; target="$2"; shift 2 ;;
--to)
need $# "$1"
case "$2" in ''|*[!0-9]*) die 2 "--to takes a message's number (the bridge's message says it)" ;; esac
to="$2"; shift 2 ;;
--file) need $# "$1"; file="$2"; shift 2 ;;
-) text="$(cat)"; got_text=1; shift ;;
-*) die 2 "unknown option: $1" ;;
*) [ -z "$got_text" ] || die 2 "reply takes one text (quote it)"; text="$1"; got_text=1; shift ;;
esac
done
if [ -n "$file" ]; then
[ -f "$file" ] || die 2 "no such file: $file"
if [ -L "$file" ]; then die 2 "refusing a symbolic link: $file"; fi
[ -O "$file" ] || die 2 "not your file: $file"
if [ -n "$(find "$file" -prune \( -perm -g+w -o -perm -o+w \) -print)" ]; then
die 2 "file is writable by group or others: $file"
fi
text="$(cat "$file")"; got_text=1
fi
[ -n "$target" ] || die 2 "which page? foldrender reply --page PAGE [--to N] TEXT"
[ -n "$got_text" ] && [ -n "$text" ] || die 2 "what is the answer? foldrender reply --page PAGE [--to N] TEXT (or - for standard input, or --file FILE)"
page_id "$target"
printf '%s' "$text" > "$TMP/reply.txt"
{ printf '{"text":'; json_str "$TMP/reply.txt"; [ -z "$to" ] || printf ',"to":%s' "$to"; printf '}'; } > "$TMP/reply.json"
ACCEPT="application/json"
api_auth POST "/api/artifacts/$PAGE_ID/agent/reply" -H "Content-Type: application/json" --data-binary "@$TMP/reply.json"
ACCEPT="text/plain"
case "$STATUS" in
2??) printf 'replied in the sidebar (to message %s)\n' "$(json_get raw to 2>/dev/null || printf '?')" ;;
404) die 7 "$(json_get str error 2>/dev/null || error_text)" ;;
409) die 1 "$(json_get str error 2>/dev/null || error_text): run foldrender bridge --page $PAGE_ID --session NAME" ;;
413) die 5 "$(json_get str error 2>/dev/null || error_text)" ;;
429) die 4 "$(json_get str error 2>/dev/null || error_text)" ;;
*) fail_http ;;
esac
}
# sha256_of FILE: its sha256, in hex (sha256sum, or shasum on a Mac).
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
else shasum -a 256 "$1" | awk '{print $1}'; fi
}
cmd_help() {
case "${1:-}" in
'') cat <<'EOF'
foldrender publishes HTML pages as links and reads pages back as text.
publish FILE... publish (or update) pages; prints each address
edit PAGE change part of a page: --old TEXT --new TEXT
read PAGE the page as Markdown, with its annotations (notes, marks,
ink) when signed in; stops at ~6k tokens (--section N)
ls [WORDS] your newest pages, id and address (--all, --duplicates)
share PAGE... +login -login --public --link --private --reset-link
log PAGE the page's history (`versions`); restore REV; undo; diff; rm --rev
canvas FILE|PAGE... a canvas with these as cards (up to 24), --note, --link
canvas edit CANVAS add or remove cards, notes and connectors in one step
answers PAGE answers to the page's questions (data-fr-choice); --wait
desk / thread / pack your desk, your messages, context packs (help pack)
bridge --page PAGE connect an agent (or --session NAME) to the page's sidebar
rm PAGE... / open PAGE delete pages / print a page's address and open it
login / logout / whoami / auth / export [FILE] / account delete / version
PAGE is a page's address, its id, or an old a.foldrender.dev link.
--json prints the API's answer. More: foldrender help COMMAND (or COMMAND
--help), foldrender help choices (questions on a page), foldrender help exit.
EOF
;;
publish) cat <<'EOF'
foldrender publish FILE...|- [options]
Publishes each file and prints its address, one line a page. stderr says
first, in one line, what happened: "created a new page", "updated page ID in
place" (with the lines added and removed), "already had this content" (nothing
written), or "no new page made: this content is already your page ID" (you
published the same page within the last day, or this is a retry of a publish
that did arrive); then other pages of yours with the same content or title,
and the commands to change, read or delete the page.
A Markdown file (.md, .markdown) is published as the page made of it, as the
iOS app does: headings, lists, task lists, tables and code (up to 256 KB).
Publishing the same file again updates the same page. A retry never makes a
second page: each publish carries an Idempotency-Key, sent again on a retry
(a dropped connection is retried twice by itself).
--private only you (the default for a new page)
--link anyone with the link; the link carries a key (?k=)
--public anyone, and listed on your page .../artifacts/LOGIN/
--share a,b only these GitHub logins
--name NAME the address's last part (default: from the title)
--folder FOLDER .../artifacts/LOGIN/FOLDER/NAME
--title TEXT the title (default: the file's ; a Markdown
file's front matter title:, else its first # heading)
--slug ID|ADDRESS publish to that page: update yours, or make a page whose
id and address (its last part) are ID. If ID is the
address of another of your pages, nothing is made and
the error says that page's id (exit 12)
--allow-duplicate make a new page even if you have one with this content
--idempotency-key K name this publish (8-120 of A-Z a-z 0-9 . _ : -): the
same key within a day answers with the page it made
--json the API's answer, one JSON object a page: action
(created, updated, unchanged, exists), id, url, access,
content_hash, duplicates and same_title (other pages of
yours, {id, url}), requested_id and not_applied (exists)
Examples: foldrender publish report.html --link
foldrender publish notes.md
foldrender publish a.html b.html c.html --folder q3
EOF
;;
edit) cat <<'EOF'
foldrender edit PAGE --old TEXT --new TEXT [--old TEXT --new TEXT ...] [--base REV] [--title T]
foldrender edit PAGE --edits FILE|- (JSON: [{"old": "...", "new": "..."}, ...])
Replaces each --old with its --new in the page's HTML, in order, on the
server: only the changes are sent. Each --old must be in the page exactly
once (as the edits before it left it), or nothing changes (exit 10).
--base REV refuses if the page is no longer at that revision (exit 10).
Prints the address; stderr says the new revision and the lines changed.
EOF
;;
read) cat <<'EOF'
foldrender read PAGE [options]
A few facts (title, address, id, updated, size), then the page as Markdown.
A long page stops near 6k tokens (FOLDRENDER_READ_MAX_TOKENS) and ends with
how much is left, the command that goes on, and its sections by number.
A canvas reads as its map first (its cards with their ids, its notes, boxes
and connectors), then its cards' text in reading order.
--outline the headings, numbered, each with its size; on a
canvas, the map alone
--section N|TEXT one section (its number in the outline, or its heading)
--card ID one card of a canvas: its id (or a unique start of it),
its number in reading order, or its title
Signed in, the page's or canvas's annotations come too: notes, highlighted
passages with their comments, boxes and handwriting as recognized, each with
its author, in a Notes section at the end (a canvas: in its map), and the
facts say how many. Only the owner and collaborators see them; anyone else
is told so.
A page with questions (data-fr-choice) shows each where it stands, and,
signed in as its owner or a collaborator, an Answers section at the end
(foldrender answers PAGE for those alone, or to wait for them).
--no-notes leave the annotations and answers out (--notes asks for
them even when not signed in)
--from N go on from character N (the next: line says which)
--max-tokens N stop near N tokens; --max-chars N near N characters
--all no stop
--meta full every fact (owner, access, sharing, expiry, type)
--json the API's JSON; --html the stored HTML
--save FILE save the stored HTML; --assets DIR save images and SVG
EOF
;;
thread) cat <<'EOF'
foldrender thread COMMAND [--json]
Your messages on your desk (foldrender.dev/desk, 全部会话): each thing you
said to your assistant is the root of a tree, and under it what came of it:
the assistant's replies, the sessions sent to work on it (with where each
is now, from the machines' own reports), what each reported, and the
sessions those started (a review, the next round, a take-over). Written by
your agents with the desk's agent token (FOLDRENDER_DESK_TOKEN) or your
sign-in; the page draws them.
new TEXT|--text-file FILE|- [--key t-...] [--at WHEN] [--by NAME]
[--parent t-...] [--reply-to-ref REF] [--channel C] [--ref REF]
a thing you said: prints its key (t-MMDDHHMM-… unless
--key). The same --key or --ref again writes nothing.
From a hook, pass the words on stdin (--text-file -):
on a command line they are in the process table
--parent: the thread it follows on from (a reply in chat)
--reply-to-ref: the chat id of the message it answers;
when that is kept here this follows its thread, else
this opens at the root
reply KEY TEXT|--text-file F [--kind reply|progress|result|ask]
[--via SESSION] [--by NAME] [--at WHEN] [--ref REF]
added under it, never rewritten. --via SESSION: the
session's report (drawn under that session); --ref
(a chat message id): the same ref again adds nothing
reply --reply-to-ref REF TEXT ... the same, under the thread of the
message it answers (the chat id of that message; no
such id yet: exit 7, nothing written)
link KEY SESSION [--machine M] [--agent A] [--parent SESSION] [--closed]
a session sent to work on it. --parent: the session it
was started for (else read from the names: hm275-rev2
goes under hm275-rev); --closed when it is closed
status KEY received|doing|you|done
where it stands (else: from its last reply)
ref KEY[#seq] --add REF... more chat ids a message came to have (the
chat answers with a sent message's id only after):
one already kept elsewhere is refused with its address
find --ref REF which message a chat id became (t-… or
t-…#seq)
move KEY --into KEY a thread opened by mistake goes into
another: its key still answers, and new replies land
on the other
ls [--limit N] the newest, with their sessions and last reply
show KEY [--json] one thread: the words, its sessions, its replies
import FILE|- lines of JSON: the assistant's threads.jsonl ({type:
thread|reply|link, ...}) and its record of the sessions
it started ({machine, name, kind, at, closed}); 100 lines
a request, safe to run again (nothing is added twice, and
20 lines already there cost what one new line does). An
import writes about 2,500 thread lines a day at most
(half of an account's share): past that it stops, exit
4, says how many lines are still to go, and the same
command after 00:00 UTC goes on
hook install claude|codex [--author NAME] [--channel C] [--skip-file FILE]
put the two hooks into the agent's settings: each
message it gets becomes a thread, its last reply of
the turn goes under it. Claude Code's hooks land in
~/.claude/settings.json, Codex's in ~/.codex/hooks.json
hook claude|codex prompt|stop what those hooks run (never in the
agent's way: a failure is silent and exits 0)
What looks like a secret in what was said is kept as [hidden]; a name that
looks like one is refused. Limits a day: 20000 writes (import lines count),
20000 hook calls.
EOF
;;
pack) cat <<'EOF'
foldrender pack COMMAND [--json]
A context pack: what you pick out of your threads, your pages and your
files to hand to another session or agent, kept as it was when picked, each
item with where it came from and when. Read later, it says what has changed
since (newer messages in a thread, a page published again, a file changed
here) and that it is background, not instructions. FoldRender calls no
model for it: what belongs in a pack is for your agent to find.
new ITEM... [--goal TEXT] [--name NAME] [--dry-run] [--redact]
make one, the items in the order given; prints its id
--thread t-KEY[#N|#N-M|#N-] a thread of yours (the desk app's), or its
messages N to M (N-: from N on)
--page PAGE one of your pages, as Markdown
--file FILE a file's text (over 16 KB: only its path and hash)
--text TEXT|--text-file FILE|- [--label L] words of your own
--dry-run prints the pack and keeps nothing (to read
threads with their message numbers before picking).
--name: the same name and items again writes nothing;
other items under it make a new pack that takes the name
show ID|NAME the pack as text, to paste into a new session; its files
are compared with this machine's at the same path
ls / rm ID|NAME... your packs, newest first / remove packs
A file or text that looks like it holds a secret (a key, a token, a private
key, a link's ?k=) is refused before anything is sent; --redact keeps each
as [hidden]. Limits: 20 items (8 pages), 16 KB an item and 64 KB a pack (an
item over is kept only as where it is, never cut), 100 packs, 100 makes and
1000 reads a day. Signed in, or with the desk's agent token.
EOF
;;
desk) cat <<'EOF'
foldrender desk COMMAND [--desk ID] [--json]
A desk is a private page of yours that answers, at a glance, what waits on
you, what is running and where it is stuck, the machines and seats, what was
done today and what happens next. Your agents write it; you answer each
question on the page with a tap (6 seconds to undo), and they take your
answers from its inbox. Open it signed in (only you can): at
https://foldrender.dev/desk where your account has the desk app (init says
which), else at its page address. It is not one of your pages: ls does not
list it, and it takes no page's place or storage.
init [--name desk] [--title T] [--lang en|zh]
make your desk (or say where it is); prints its address
token add --role agent|feed --name NAME
a token for this desk alone, printed once. agent: writes
asks, tasks, done, timers, known; reads the desk and your
answers. feed: replaces one source's snapshot (sessions,
machines, seats, alerts) and reads nothing
token ls | token rm ID
ask KEY --title "the question in a sentence" [--context TEXT]
[--suggest "what you suggest, and why"] [--option LABEL]... (alternatives)
[--product P] [--deadline 2026-10-23[T10:00]] [--heavy] [--device D]
[--minutes N] [--link URL[=LABEL]]... [--reply-url URL] [--base REV]
[--hands] the owner's own to do (buttons: do it now, remind me later)
[--input] answered by writing (no "agree"; --suggest says how to fill it)
task KEY --title T [--product P] [--state doing|you|external] [--session NAME]
[--next "10-23 10:00 check"] [--link URL[=LABEL]]...
timer KEY --title T --at 2026-10-23T10:00 [--star]
known KEY --match "text of an alert" --note "why it is known" [--until WHEN]
done KEY... [--note "what was done"] rm KEY...
feed SOURCE FILE.json|- a snapshot: {"every_s": 60, "items": [{key, kind:
session|host|seat|alert|timer, title, product?, state?,
body?}]}; what it no longer names is removed
ls the desk as text (also: foldrender read desk, your
own desk wherever it is; FOLDRENDER_DESK names another)
inbox [--all] [--after ID]
your answers that count and no agent has taken yet
ack ID --sent | --doing | --done "what was done"
mark an answer; the page shows each step under it
machine add NAME [--replace] | machine ls | machine rm ID
the machines that report to the desk, each with a token
of its own (frdm_..., printed once by add, alone on
stdout) that does nothing else; rm revokes it, and what
that machine reported leaves the desk
image FILE a picture a question may show (a PNG, JPEG or WebP, at
most 200 KB): prints its id, for the card's images
[{"id": ID, "label": "A ..."}] (at most 3, each label one
line of at most 40 characters). Kept while an open
question shows it, and a day more
owner [--name NAME | --clear]
what the desk calls you (one line, at most 32
characters); its page shows the first character, and
your login's first letter while there is none. With no
option, prints it
agent install [--name NAME] [--replace] [--token-file FILE|-] [--no-start]
on a machine, keeps the desk agent running: a LaunchAgent
on a Mac, a systemd user unit on Linux (crontab, at boot
and every minute, where a user unit would stop at logout
or there is none). It tells the desk what runs in herdr
here (each agent, its state, the last line it printed)
and how loaded the machine is, only when that changed,
with tokens, keys and passwords made [redacted] before
anything leaves. With no token here it makes the machine
with your sign-in (NAME: this host's name); --replace
gives it a new token. Needs Node 22 or later
agent status installed? running? connected? (exit 1: not running)
agent uninstall [--keep-token] | agent run [--detach]
The same KEY again changes the thing (its revision goes up; an answer to an
older revision is refused). Anything that looks like a secret (a key, a
token, a link's ?k=) is refused whole; links go to foldrender.dev,
github.com and Feishu only. Write sentences and numbers, never terminal
output. --desk ID (or FOLDRENDER_DESK) names another desk; the default is
your own desk (the one named desk; with a desk token, that token's desk),
its id kept by desk init.
FOLDRENDER_DESK_TOKEN: a desk token (frdk_...) instead of your sign-in; keep
it in the environment or a 600 file, never in a command line.
Examples: foldrender desk ask aws-pay --title "Bind a card for the new models?" \
--suggest "Yes: set a budget alert first" --option "Not now" --heavy
foldrender desk inbox; foldrender desk ack 12 --done "card bound"
The desk's second version (its page at https://foldrender.dev/desk once it is
on for your account; until then at /desk?v2=1). Every question is a card of
one shape, answered the same way: agree to your suggestion, another option,
"not now, ask me tomorrow" (the page adds it), or the owner's own words. An
answer is a draft until the owner presses Send (发给你的 agent); nothing else
sends it. What they send reaches their agents as one batch, from the outbox,
and can be taken back until it is marked done.
card KEY --question "one line" --suggest "what you would do"
[--context TEXT (300)] [--option LABEL]... (at most 2) [--heavy]
[--deadline 2026-10-23[T10:00]] [--product P] [--by SESSION]
[--machine M] [--link URL[=LABEL]]... (at most 3)
[--image FILE[=LABEL]]... (at most 3)
write the card (the same KEY again rewrites it: a new
revision when its words change). No --title, --hands,
--input: the question is --question, and what you would
do (or the default, when you need the owner to tell you
something) is --suggest. Refused: no suggestion, a
question past 80 characters or on two lines, more than
2 options, anything that looks like a secret.
--image: a picture the question shows (a PNG, JPEG or
WebP, at most 200 KB; LABEL one line of at most 40
characters), sent first as desk image does
card KEY --close [--done]
take it off the desk (it is not needed any more).
Nothing is sent: an answer the owner picked on it and
has not sent stays theirs, the card with it on their
page, and the command says so
cards the open cards: KEY, revision, how far its answer is
outbox what the owner sent and you have not been handed: each
#ID with the words to hand on (a batch of answers, an
answer taken back, or a warning that too much waits)
outbox ack ID... you have them (until then they are handed again)
answers [--since NEXT] [--limit N]
what the owner sent, for you alone: each batch (and
each answer they took back) after where your token got
to, never a draft. Every agent with its own desk token
reads on its own, so several read the same batches
without taking them from each other; the last line says
next (--since NEXT reads from there again). Your place
moves when the desk answers: keep the next of the last
read you handled, and after a read that failed, read
again with --since that next
mark BATCH read|doing|done [--result "one sentence"] [--key KEY]... [--all]
how far you are with a batch's answers: KEY's (a
--key each), or --all of them alike. A batch of one
answer needs neither; one of several is refused
without them (the error names its keys). --result goes
with doing or done and is the same for every answer
named: mark each answer with its own. The page shows it
under each answer
mark BATCH read|doing|done --key KEY [--result "..."] --correct
an answer you marked, set as it should be: back or
forward, its result replaced (none if not given); what
it said before is kept and the page says it was
corrected. Only the token that marked it corrects it
batches [--limit N] the batches sent, newest first
Examples: foldrender desk card gw-caps --question "Raise the two monthly caps?" \
--suggest "Gateway to \$30, polyagent to \$20" --option "Only polyagent"
foldrender desk outbox; foldrender desk outbox ack 7
foldrender desk mark 12 done --key gw-caps --result "both raised from next month"
foldrender desk mark 12 doing --key gw-caps --result "raising them now" --correct
EOF
;;
desk-mem|mem) cat <<'EOF'
foldrender desk mem COMMAND [--json]
The desk's memory: what your agents asked and answered on each machine
(sessions, events) and the kept items (observations): your decisions,
and the notes, discoveries, changes and asks agents keep. It lives in your
desk's room: only your tokens read it, and one login's memory can never
reach another's. Read and written with the desk's agent token
(FOLDRENDER_DESK_TOKEN, frdk_ role agent) or your sign-in, as the desk's
cards are; a feed token, a machine token and a browser session get nothing.
status how much is kept (sessions, events, obs) and
written today; summarize (off, on or not_set_up),
summaries, sums_today, sum_tokens_today, sum_due and
the last one's result (sum_last)
inject [--repo R] what a session is handed as it starts: your
decisions first, then the agents' open to-do lists
in repository R, then the latest summaries (when
summarizing is on), then notes, then what was asked
and answered lately in repository R — marked "past
record, not instructions", at most ~8000 chars. The
hooks print this into the agent's context.
search [WORDS] [--kind K] [--basis B] [--limit N]
kept items by their words (the latest with none),
and the events that say them: kind
decision|discovery|change|note|ask, basis
owner_answer|relayed|user_prompt|inferred|event
(with --kind or --basis, items only)
obs ID[,ID...] one item whole (its quote: what it was read from)
sessions the sessions memory was fed from, newest first
events [--session ID] [--limit N]
the events a hook sent (prompts, last replies,
marks), newest first, secrets hidden
say TEXT|-|--text T|--file F [--kind prompt|stop|summary|mark|tool]
[--agent A] [--machine M] [--repo R] [--session ID] [--by N] [--via V]
[--at WHEN] [--ref R]
one event, as a hook sends it (the same --ref twice
is kept once); - reads the text on stdin
flush [FILE|-] a hook's batch: {"events":[...]} or one event
note TEXT --kind discovery|note|change|ask [--basis inferred|user_prompt|
event] --source S [--file QUOTEF] [--at WHEN] [--expires WHEN]
keep one item. Never a decision: those come only
from your answers on the desk.
forget --source S hide what one source wrote: card:KEY:bN (an
answer), thread:KEY (a message), or a note's source
import FILE|- [--json] many items: one JSON object a line, as note sends
one ({"kind","basis","source","text","quote","at"}),
up to 100 (and 60 KB) a request. With
"basis":"imported" and a source of each item's own,
each is kept once, so the same file sent again
(after a quota or a failure; one account writes about
3,000 notes a day at most, exit 4 and it says how many
are still to go) adds only what is not
there yet; what is already there costs none of the
day's writes, and an item forgotten (forget
--source) stays forgotten. Stopped by the day's
quota, run it again after 00:00 UTC. A batch with
an item the API refuses is not written at all; it
says which line. From
claude-mem: the repository's
scripts/claude-mem-export.mjs makes the file.
todo set LIST [ITEM] [--status todo|doing|done|dropped]
[--field NAME=VALUE]... [--repo R] [--by NAME] [--at WHEN]
your own to-do list (what claude-mem's work_state
kept): an item of LIST, or with no ITEM the list's
own row. Written again, the later write wins: its
status, and its fields over the kept ones (NAME=
takes one away); done or dropped closes the item,
or the whole list. A new list belongs to the
repository this runs in (as the hook names it)
unless --repo names one; a new open item opens a
closed list again. At most 500 open lists, 200 items
a list (a new one takes the place of the oldest
closed), 2 KB of fields each. Not on the owner's
page: a new session in the list's repository is
handed its open lists touched in the last 7 days
(their own ~1,500 chars of the block). When the
desk's storage is full, a new item or list, and a
close that adds a field, are refused (exit 1, in
the desk's own sentence); nothing is written
todo ls [LIST] [--repo R] [--all] [--json] [--limit N] [--offset N]
the open lists, newest first, each with its open
items (--all: closed ones too), at most 50 lists
and about 800 items a read; "more: --offset N"
when there are more
todo close LIST [ITEM] [--dropped]
done (or dropped): the item, or the whole list. A full
desk still takes this, and rm, for what is there
todo rm LIST the list gone, its items with it (a forget a row:
2,000 a day with forget)
todo import FILE|- [--from N] [--json]
many: one JSON object a line, as set sends one
({"list","item","status","fields","repo","at"}), in
order, 100 a request (each line one of the day's
writes). A line refused, or that is not one JSON
object, is said by its number (the first 20, then how
many more) and the others go in; exit 1 when every
line was refused, and a pretty-printed file stops
after its first request. Too many changes in a minute
is waited out; the day's quota stops it and says the
line to go on from: run it again with --from N (a
line sent twice is unchanged). From claude-mem's
work-state lists: scripts/claude-mem-export.mjs
--work-state
install [--agent claude|codex|all] [--remove]
on this machine, the hooks that feed the memory
(default claude and codex): a session's start prints
desk mem inject into it; each prompt, and the last
reply at a stop, are sent as events. A desk agent
token (frdk_) is made with your sign-in and kept in
~/.config/foldrender/desk-mem/token (600), never
printed; what was said reaches the hook on stdin,
never a command line. Codex runs the hooks once you
trust them in its /hooks. --remove takes them out.
agy, grok, pi and opencode have no hook yet: use say
and inject.
A decision comes only from your answer to a card on your own desk page
(owner_answer); nothing an agent sends can be one, whatever token it
holds, your own sign-in included (agents run this command signed in as
you). Taking the answer back takes the decision with it. A message on the
desk (foldrender thread new) is kept as a note of what you said, as it
was relayed. Secrets in what was sent are kept as [hidden] and counted; a
name that looks like one is refused. Events age out (~14 days); a whole
account's memory goes with the desk. A day's writes and reads are capped
(429 quota until the next UTC day).
Summarizing is off unless the desk's server turns it on: then, a little
after a session stops, a model reads what it said since it was last read
and keeps a summary (request, investigated, learned, completed, next
steps) and a few items, all
basis inferred, source sum:SESSION. A decision it reports is kept as a
note, one it calls sensitive not at all, and what it wrote is masked for
secrets again. Its calls and tokens are capped per day; nothing you run
calls it, and nothing here calls a model when it is off.
Examples: foldrender desk mem install --agent claude
foldrender desk mem inject --repo foldrender
printf '%s' "$LAST_REPLY" | foldrender desk mem say - --kind stop --agent grok --machine devdesk
foldrender desk mem search "release window"
foldrender desk mem note "the retry loop was the bug" --kind discovery --source claude:fr-desk
foldrender desk mem import claude-mem.jsonl
foldrender desk mem todo set ship-1101 "merge #307" --status doing --field next="wait for CI"
foldrender desk mem todo close ship-1101 "merge #307"
EOF
;;
answers) cat <<'EOF'
foldrender answers PAGE [--wait [--after N] [--timeout S]] [--json]
foldrender answers PAGE --notify NAME|-|default
foldrender answers PAGE --clear
foldrender answers --inbox | --ack ID,ID --to NAME
A page asks questions with data-fr-choice blocks (foldrender help choices);
its owner and collaborators answer them on the page, signed in, with a tap.
This prints each question with its options and the answers: the latest per
person, with the suggestion, right or wrong, a note, what it changed from.
An answer counts once its undo window (6 s) is over. Reading them marks them
read by an agent, which the page shows.
--wait wait for an answer no agent has read yet (returns at once
if there is one); with --after N, for one newer than
answer N (the latest: line says N)
--timeout S stop waiting after S seconds (default 600; exit 11)
--notify NAME whom the owner's relay tells of new answers on this page
(over the page's own fr-choice-notify); - for no one,
default for the page's own again
--clear delete every answer on the page (owner; after a trial)
--inbox answers on your pages nobody has been told of yet, one a
line: id, page, whom to tell, question, answer, who, when
--ack IDS --to NAME mark those told (a relay does --inbox, tells, --ack)
--json the API's answer
EOF
;;
bridge) cat <<'EOF'
foldrender bridge --page PAGE [--dir DIR] [--agent claude|codex|gemini|antigravity|grok|devin|pi] [--mcp FILE]
foldrender bridge --page PAGE --session NAME [--mesh-cli PATH]
foldrender bridge --page PAGE --channel
Connects a coding agent on this computer to PAGE's agent sidebar, and stays
connected until stopped (Ctrl-C). The page's owner and its collaborators talk
with the agent beside the page, in a browser; it reads the page with
foldrender read, works in DIR, and asks before it acts as its own settings
say. Its questions go to the page, where only you (the owner, who runs it)
answer them. Unless the agent is sure to ask you first (Claude Code: its
mode is default or dontAsk; no allow rule but reading inside DIR, no other
folder, no PreToolUse or PermissionRequest hook, no self-running sandbox,
no plugin, nothing in what the organisation or an MDM puts on this computer
(a managed settings file, its drop-ins, a macOS or Windows policy, or what
its admin console delivers); Codex: its read-only mode; pi never asks;
Gemini, Antigravity, Grok and Devin are not known), what your collaborators say waits for
you to hand it to the agent; it says so on start. Messages sent while the
bridge is away wait for it. The page's
Markdown panel (its plan) is kept in DIR/.foldrender/PAGE/plan.md: the
agent's edit there is written back to the panel, and if someone changed the
panel meanwhile, the people on the page choose which text stays; the text
not taken is kept beside plan.md and in the conversation.
Started again (or after the agent stopped), the bridge goes on in the
agent's own session for each conversation, which remembers it (the agent's
session/load or session/resume: Claude Code, Codex, Gemini CLI, Grok, Devin
and pi offer one), and tells it only what was said meanwhile on another
computer; when that session is gone, or on another computer, a new one is
told the conversation so far. Which session each conversation is in is kept
in ~/.cache/foldrender/sessions (ids only). A terminal session (--session)
that did not have the conversation gets it with the next message.
What people attach in the sidebar (at most 4 files a message, 4 MB each)
reaches the agent: an image as an image and a text file as its text, when
the agent takes them; every file but an image it takes is also saved in
DIR/.foldrender/PAGE/attachments/ and named in the message. The agent's
forms (a question with fields to fill) go to the page, where only you
answer them; its slash commands are listed when you type "/" in the box,
and yours is run as typed (anyone else's "/word" is a message). The title
the agent gives a conversation, and its usage (the context it holds, and
what it cost, which only you see), show in the sidebar. People there can
copy a message or the whole conversation (as Markdown), search it, pin one
message for everyone, and see who has read it how far ("Seen by @login"
under the last message: what their sidebar had on screen).
The bridge offers the agent ACP's file and terminal requests: a file in
DIR is read or written as asked, a command runs in DIR, and what a command
prints shows in its line in the sidebar (also for a command the agent runs
with its own tool, when it says what that printed, as pi does). A file or
a command's folder
outside DIR (its links followed) is asked of you in the sidebar first.
This fence is the bridge's own: an agent's own tools do not come through
it, so it is no sandbox, and a command run in DIR can still reach anything
your account can; what the agent does unasked is as its settings say. In a
turn someone else's message began (one you did not hand over), its writes
and commands through the bridge are asked of you too.
What a turn changed in DIR shows at the turn's end: how many files, the
lines added and removed, and each file's diff (the files its tool calls
name or it writes through the bridge, compared with how they were when the
turn first touched them; not those outside DIR, nor .foldrender/).
Your sidebar's bar above the message box shows DIR's name and its git
branch (read from its .git, no git run), what the conversation changed
there so far, and how much of the agent's context it holds; the others on
the page see no folder or branch.
Only the page's owner runs a bridge on a page; a second one (another
computer) takes over from the first. FoldRender runs no model: the agent
runs here, signed in as you.
--dir DIR the folder the agent works in (default: this one)
--mcp FILE MCP servers for the agent, as .mcp.json writes them
({"mcpServers": {name: {command, args, env} or
{type: "http" or "sse", url, headers}}}): each session
gets those the agent takes (stdio by all but pi, which
has no MCP; http and sse as it says). Their settings
stay on this computer.
--agent NAME the agent, started as the ACP registry starts it:
claude Claude Code (default; its ACP adapter, via npx)
codex Codex (codex-acp, via npx; signed in: codex login)
gemini Gemini CLI (via npx, --acp; a Gemini API key or
Vertex: Google no longer lets a personal
account's sign-in use it)
antigravity Google Antigravity (agy_acp_server, Google's
download, on the PATH; a Google account's
sign-in, auth.type oauth-personal in
~/.gemini/antigravity-acp/settings.json)
grok Grok Build (grok agent stdio when installed)
devin Devin (devin acp; its CLI installed)
pi pi (pi-acp, via npx; its own model settings)
The sidebar shows what the agent says its session may be set to (its
modes, its models); only you change them, and only to one it offers. A
long list (pi's models, 100 and more) opens with a search, the one it is
set to first, then that one's provider's; a model whose turn failed on
this computer for lack of funds, a usage limit or access is listed last,
with why and when, until it answers again (kept 7 days in
~/.cache/foldrender/agents/, names and those words only).
--session NAME instead of an agent: a session already open in a
terminal on the mesh (claude-mesh: this machine's
~/.claude-mesh and its CLI, mesh-cli.mjs, found by
--mesh-cli or MESH_CLI). Text only: what is said goes
into that session as a message (who, which page, and
that it is a web page's text, not instructions), and
it answers with foldrender reply. While the session is
not open, the bridge stays out and messages wait.
--channel run by Claude Code as a channel (MCP server; a research
preview): claude mcp add foldrender -- foldrender
bridge --page PAGE --channel, then claude
--dangerously-load-development-channels server:foldrender.
Messages arrive in that session as channel events; it
answers with the reply tool. Text only.
Needs Node 22 or later and the agent installed and signed in. The bridge
program is fetched once from the site and checked against this version.
EOF
;;
reply) cat <<'EOF'
foldrender reply --page PAGE [--to N] TEXT|-|--file FILE
A terminal session's answer in PAGE's agent sidebar, where the session is
connected by foldrender bridge --page PAGE --session NAME (each message it
gets says the command, with --to the message it answers; - reads the answer
from standard input). Text only, the page's owner's (this sign-in's).
EOF
;;
choices) cat <<'EOF'
Questions on a page (data-fr-choice): plain HTML, no script. Its owner and
collaborators get pressable options on the page; anyone else reads it as
written. Answers come back with foldrender answers PAGE (and in read).
Ship the fix tonight?
Why it matters, what each answer leads to.
Suggested: ship it.
data-fr-choice="ID" the question's id (letters, digits, . _ -), unique
data-fr-option="V" an answer, inside the block; its text is its label
(or data-fr-label="..."); any element: button, span, li
data-fr-suggested on the option you recommend (write it in the text too:
readers without buttons see only the text)
data-fr-multi any number of options, sent with a Send button
data-fr-note the answerer may add a sentence (value: placeholder);
a block with no options and data-fr-note is an open
question
data-fr-question the question's text (else the block's first heading)
data-fr-correct a quiz: the right option(s); [data-fr-explain] inside
shows once answered; data-fr-topic, data-fr-set group them
whom to tell of new answers
Up to 50 questions a page, 20 options each. Answering needs a person signed
in on the page (the owner, or a collaborator); a token never answers. To let
someone else answer: foldrender share PAGE +LOGIN --collab (or the page's
Share > Collaborate).
EOF
;;
ls|list) cat <<'EOF'
foldrender ls [WORDS...] [--all|-n N] [--access public|link|private|shared] [--folder F] [--json]
Your pages, newest first: the 20 newest unless --all or -n N, each with its
id (what commands take) and its address. WORDS must all be in the id, title
or address.
foldrender ls --duplicates [--json]
Your pages that have the same content as another of yours, in groups, the
oldest first, with the rm command that would delete the others. Deletes
nothing.
EOF
;;
share) cat <<'EOF'
foldrender share PAGE... [+LOGIN ...] [-LOGIN ...] [--private|--shared|--link|--public]
[--reset-link] [--collab|--no-collab] [--copy|--no-copy]
Changes who can open the pages, without publishing them again; prints each
address, and on stderr who can open it, as the site's Share panel says it.
--private Only you
+LOGIN / -LOGIN add or remove GitHub logins, as the Share panel does: a
private page becomes Only people added; a --link or
--public page stays so, the people kept on its list; the
last one removed makes a shared page private again
--shared Only people added: the logins on its list, signed in
--link Anyone with the link (it carries a key; listed nowhere)
--public Public: anyone, and listed on .../artifacts/LOGIN/
--reset-link a new key for a --link page: the old link stops working
--collab readers who sign in can write on it (ink, notes, and
answering its choices); --no-collab turns it off
--copy / --no-copy whether readers may copy it to their own space
--json the API's answer
Examples: foldrender share plan +alice --collab
foldrender share plan --link
EOF
;;
canvas) cat <<'EOF'
foldrender canvas FILE|PAGE...|PLAN.canvas [options]
A new canvas: each file or page becomes a card (up to 24; a card 2 MB at
most, the files 5 MB together). Files go up as cards only, never as pages.
Prints its address; stderr says what it holds and its revision.
--note TEXT a note, which --link calls note:1, note:2, ...
--link A:B[:LABEL] a connector from A to B: a card's number, a file or
page as given, or note:N
--name NAME its address's last part; the same command again
replaces that canvas, or writes nothing if unchanged
--folder F --title T --private (default) --link-access --public
--share a,b --owner LOGIN (admin token) --json
PLAN.canvas a JSON Canvas file (or .json), sent as it is, with the
local files its "file" fields name (next to it)
foldrender canvas edit CANVAS [--base REV] [--add FILE|PAGE]... [--note TEXT]...
[--link A:B[:LABEL]]... [--rm ID]... | --patch FILE|-
Changes the canvas in one step, all of it or nothing (exit 10). A and B: an
id on the canvas (or a unique start of it), an --add argument, or note:N.
Prints the address, then the new map; stderr: the revision and new ids.
Examples:
url=$(foldrender canvas plan.html risks.html --name q4 \
--note 'Risks first' --link note:1:2 --link 2:1:feeds)
foldrender canvas roadmap.canvas --name roadmap
foldrender canvas edit "$url" --add costs.html --link RfvO:costs.html:funds
EOF
;;
rm|delete) cat <<'EOF'
foldrender rm PAGE... [--json]
Deletes each page: its address, its kept versions and its annotations. It
cannot be undone. Prints one line a page: "deleted page ID ("TITLE"); ADDRESS
no longer opens". Exit 1 if any page was not found (the others are deleted).
EOF
;;
open) cat <<'EOF'
foldrender open PAGE
Prints the page's address (its canonical one, for an id or an old link) and
opens it in the browser (not with FOLDRENDER_NO_BROWSER=1).
EOF
;;
export) cat <<'EOF'
foldrender export [FILE.zip]
Saves every page of the account and a manifest.json as one zip (default:
foldrender-artifacts-LOGIN-DATE.zip) and prints its path. Never overwrites.
Pages that add up to more than one download holds (64 MB) are saved in
parts, FILE-1-of-K.zip to FILE-K-of-K.zip, each with a manifest.json of its
own pages; it prints each path, one a line, once the set is whole, and says
on stderr how many pages and how much each holds. If pages change while the
parts are saved, each part that changed is saved again. A part that fails,
or a daily export limit (exit 4, said at once), removes the parts saved
before it, so a set on disk is always whole. Each zip is fetched from a
2-minute download link on the account's own host, with no token; an account
downloads at most 100 parts a day.
EOF
;;
login|logout|whoami) cat <<'EOF'
foldrender login sign in: prints a link and a code to approve.
Another account? Approve the link as it (switch
accounts in the browser first): it is added, and
becomes the default; the others stay signed in
foldrender login --token TOKEN save a token (from the account page) instead
foldrender whoami the login this command runs as, plan and pages used
foldrender auth list the accounts signed in here, the default marked
foldrender auth switch LOGIN make LOGIN the default (with two, the other one)
foldrender logout [LOGIN|--all] revoke the token on the server and remove it here:
the account in use, LOGIN, or every one
Which account a command runs as, first to last: FOLDRENDER_TOKEN (a token; not
one of the saved accounts), --as LOGIN anywhere on the command line or
FOLDRENDER_ACCOUNT=LOGIN (a saved account: an agent sets this, so that a switch
in a terminal does not move it), the default. Each account keeps its own map of
published files, so publishing a file again updates that account's page.
EOF
;;
account) cat <<'EOF'
foldrender account delete [--confirm LOGIN]
Deletes the account, every page and every API token; it cannot be undone.
Asks for the login on a terminal; without one, --confirm LOGIN is required.
Run foldrender export first to keep a copy.
EOF
;;
versions|restore|undo|log|diff) cat <<'EOF'
foldrender log PAGE the page's history, a line a revision: when, who · client,
+added/-removed, its message, gone: once a body is gone;
`versions` is its old name. A page keeps the bodies of its
newest 10 versions; past 1000 revisions the oldest are
folded into one line. A page of 100 rows at a time: the
last line says how to ask for the ones before it
(--before REV, as it names).
foldrender diff PAGE [REV [REV]] [--html] rev A to B as a unified diff (the text a
read gives, or the stored HTML with --html); with no
revisions, the one before the current against it
foldrender restore PAGE REV|VERSION put a revision (or kept version id) back, as the
page's next commit ("restore rev N" in the log)
foldrender undo PAGE put back the revision before the current; undo again to return
foldrender rm PAGE --rev REV|--version ID delete one version's body: its link then says
it was deleted; its log entry stays
foldrender publish FILE -m MSG, foldrender edit PAGE ... -m MSG
the publish or edit's message in the log
EOF
;;
exit) cat <<'EOF'
Exit codes: 0 ok, 1 failed, 2 bad usage, 3 not signed in or token rejected,
4 plan limit reached (or a canvas save or export limit), 5 page too large, 6 network
failure, 7 no such page or not visible to this account, 8 page expired, 9 too
large to read as text (use --html), 10 an edit did not apply (old text missing
or not unique, the page moved on from --base, or a canvas edit was refused),
11 no new answer before --timeout (answers --wait), 12 the id or address
publish was asked for is another page's (the error names the page and the
command to update it).
Environment: FOLDRENDER_TOKEN (a token instead of the saved accounts),
FOLDRENDER_ACCOUNT (a saved account's login, as --as),
FOLDRENDER_API (the API, for testing), FOLDRENDER_NO_BROWSER=1,
FOLDRENDER_READ_MAX_TOKENS, FOLDRENDER_NO_UPDATE_NOTE=1 (never say that a
newer CLI is out). Free plan: 10 pages, 30 days each, 5 MB a page.
EOF
;;
*) die 2 "no help for $1; try: foldrender help" ;;
esac
}
# help_asked CMD ARG...: whether ARGs ask for CMD's help (-h or --help), not
# counting the value of an option that takes one (edit --old --help).
help_asked() {
local cmd="$1" skip="" a
shift
for a in "$@"; do
if [ -n "$skip" ]; then skip=""; continue; fi
case "$a" in
-h|--help) return 0 ;;
--old|--new|--title|--name|--folder|--share|--slug|--edits|--patch|--base|--assets|--save|-o|\
--section|--card|--from|--max-tokens|--max-chars|--meta|--access|-n|--add|--rm|--note|--owner|\
--confirm|--token|--timeout|--after|--notify|--ack|--to|--idempotency-key|--page|--agent|--dir|\
--desk|--role|--context|--suggest|--option|--deadline|--product|--state|--device|--minutes|--session|\
--next|--at|--match|--until|--reply-url|--lang|--done|--token-file|--log|--cli|--api|\
--repo|--source|--basis|--expires) skip=1 ;;
--link) [ "$cmd" != canvas ] || skip=1 ;;
esac
done
return 1
}
main() {
local cmd="${1:-help}"
[ $# -eq 0 ] || shift
case "$cmd" in
login|logout|auth|account|export|whoami|publish|edit|share|list|ls|read|cat|versions|restore|undo|canvas|answers|desk|pack|bridge|rm|delete|open|version)
if help_asked "$cmd" "$@"; then
case "$cmd" in
list) cmd_help ls ;;
cat) cmd_help read ;;
auth) cmd_help login ;;
version) cmd_help ;;
desk) if [ "${1:-}" = mem ]; then cmd_help desk-mem; else cmd_help desk; fi ;;
*) cmd_help "$cmd" ;;
esac
return 0
fi ;;
esac
case "$cmd" in
login) cmd_login "$@" ;;
logout) cmd_logout "$@" ;;
auth) cmd_auth "$@" ;;
account) cmd_account "$@" ;;
export) cmd_export "$@" ;;
whoami) cmd_whoami ;;
publish) cmd_publish "$@" ;;
edit) cmd_edit "$@" ;;
share) cmd_share "$@" ;;
list|ls) cmd_list "$@" ;;
read|cat) cmd_read "$@" ;;
log|versions) cmd_log "$@" ;;
diff) cmd_diff "$@" ;;
restore) cmd_restore "$@" ;;
undo) cmd_undo "$@" ;;
canvas) cmd_canvas "$@" ;;
answers) cmd_answers "$@" ;;
desk) cmd_desk "$@" ;;
thread) cmd_thread "$@" ;;
pack) cmd_pack "$@" ;;
bridge) cmd_bridge "$@" ;;
reply) cmd_reply "$@" ;;
rm|delete) cmd_rm "$@" ;;
open) cmd_open "$@" ;;
version|--version) printf 'foldrender %s\n' "$FR_CLI_VERSION" ;;
help|-h|--help) cmd_help "$@" ;;
*) die 2 "unknown command: $cmd (try: foldrender help)" ;;
esac
}
# --as LOGIN (or --as=LOGIN), anywhere: this command runs as that saved
# account. Not the value of an option that takes one (edit --old --as).
ARGS=()
skip=""
first=""
while [ $# -gt 0 ]; do
if [ -n "$skip" ]; then ARGS+=("$1"); skip=""; shift; continue; fi
case "$1" in
--link) ARGS+=("$1"); case "$first" in canvas|desk) skip=1 ;; esac; shift; continue ;;
--as) [ $# -ge 2 ] || die 2 "--as needs a value"; AS_LOGIN="$2"; shift 2 ;;
--as=*) AS_LOGIN="${1#--as=}"; [ -n "$AS_LOGIN" ] || die 2 "--as needs a value"; shift ;;
--old|--new|--title|--name|--folder|--share|--slug|--edits|--patch|--base|--assets|--save|-o|\
--section|--card|--from|--max-tokens|--max-chars|--meta|--access|-n|--add|--rm|--note|--owner|\
--confirm|--token|--timeout|--after|--notify|--ack|--to|--idempotency-key|--page|--agent|--dir|\
--desk|--role|--context|--suggest|--option|--deadline|--product|--state|--device|--minutes|--session|\
--next|--at|--match|--until|--reply-url|--lang|--done|--mesh-cli|--mcp|--id|\
--key|--text-file|--kind|--by|--via|--ref|--parent|--channel|--machine|--agent|--limit|\
--goal|--thread|--file|--text|--label)
ARGS+=("$1"); skip=1; shift ;;
*) [ -n "$first" ] || case "$1" in -*) ;; *) first="$1" ;; esac; ARGS+=("$1"); shift ;;
esac
done
select_account
main ${ARGS[@]+"${ARGS[@]}"}