#!/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 <title>s of its # own further down, and a greedy match would pick the last of them. title_from() { tr '\n' ' ' < "$1" | grep -o '<title[^>]*>[^<]*' | 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). <div data-fr-choice="D1" data-fr-note="Anything to add?"> <h3 data-fr-question>Ship the fix tonight?</h3> <p>Why it matters, what each answer leads to.</p> <p>Suggested: ship it.</p> <button data-fr-option="ship" data-fr-suggested>Ship it</button> <button data-fr-option="wait">Wait for Monday</button> </div> 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 <meta name="fr-choice-notify" content="NAME"> 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[@]}"}