#!/usr/bin/env bash # echo.sh — the single validated client for the ECHO Obsidian Local REST API. # # Every read/write to the vault should go through this script instead of hand-built # curl. It centralizes: auth injection, HTTP-status checking (non-zero exit on >=400 # so a failed write can never look like success), one bounded retry on 5xx/connection # errors, idempotent append (read-before-POST), correct `::` heading-target handling, # frontmatter field patches, and an advisory multi-writer lock. # # Config (env overrides; defaults match the rest of the plugin): # ECHO_BASE default https://echoapi.alwisp.com # ECHO_KEY default the plugin bearer token # ECHO_VERIFY default 1 — read-back verify after a PUT # ECHO_LOCK_TTL default 900 — seconds before an advisory lock is considered stale # # Usage: # echo.sh get # print file contents (404 -> exit 44) # echo.sh map # document-map JSON (headings/blocks/frontmatter) # echo.sh ls # directory listing JSON # echo.sh search # /search/simple # echo.sh put [file] # create/overwrite (body from file or stdin) # echo.sh post [file] # raw append (NON-idempotent; prefer `append`) # echo.sh append # idempotent append: skips if the exact line exists # echo.sh patch [file] # echo.sh fm # PATCH a frontmatter scalar (e.g. fm p.md updated '"2026-06-19"') # echo.sh bump [YYYY-MM-DD] # set frontmatter updated: to today (or given date) # echo.sh delete # DELETE (destructive; explicit use only) # echo.sh lock # acquire advisory lock (exit 75 if held by someone else & fresh) # echo.sh unlock # release advisory lock if owned by # # Exit codes: 0 ok · 44 not-found(404) · 75 lock-held · 2 usage · 1 other HTTP/transport error. set -euo pipefail ECHO_BASE="${ECHO_BASE:-https://echoapi.alwisp.com}" ECHO_BASE="${ECHO_BASE%/}" ECHO_KEY="${ECHO_KEY:-241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab}" ECHO_VERIFY="${ECHO_VERIFY:-1}" ECHO_LOCK_TTL="${ECHO_LOCK_TTL:-900}" AUTH="Authorization: Bearer ${ECHO_KEY}" # response-body scratch file (filled by every _curl; read by callers via $RESP) RESP="$(mktemp)"; BODY="" cleanup() { rm -f "$RESP" "${BODY:-}"; } trap cleanup EXIT die() { echo "echo.sh: $*" >&2; exit 1; } usage() { sed -n '2,40p' "$0" >&2; exit 2; } _today() { echo "${ECHO_TODAY:-$(date +%Y-%m-%d)}"; } _now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; } _vault_url() { echo "${ECHO_BASE}/vault/$1"; } # _curl METHOD URL [extra curl args...] # Writes the response body to $RESP and the status code to $HTTP — BOTH IN THE PARENT # SHELL (never call this in $(...) or on the right of a pipe, or those globals are lost). # One bounded retry on transport failure (000) or 5xx. HTTP="" _curl() { local method="$1" url="$2"; shift 2 local code attempt=0 while :; do code="$(curl -sS -X "$method" -H "$AUTH" -o "$RESP" -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000)" if { [ "$code" = "000" ] || [ "${code:0:1}" = "5" ]; } && [ "$attempt" -lt 1 ]; then attempt=$((attempt+1)); sleep 1; continue fi break done HTTP="$code" } # assert $HTTP is acceptable; 404 -> exit 44, other >=400 -> exit 1 _check() { local ctx="$1" [ "$HTTP" = "404" ] && exit 44 [ "$HTTP" = "000" ] && die "$ctx: vault unreachable (connection failed) [$ECHO_BASE]" [ "${HTTP:-000}" -ge 400 ] && die "$ctx: HTTP $HTTP — $(cat "$RESP")" return 0 } # capture a body argument (file path or '-'/empty for stdin) into $BODY (a temp file) _capture_body() { BODY="$(mktemp)" if [ "${1:-}" = "" ] || [ "${1:-}" = "-" ]; then cat > "$BODY"; else cat "$1" > "$BODY"; fi } cmd="${1:-}"; shift || true case "$cmd" in get) [ $# -ge 1 ] || usage _curl GET "$(_vault_url "$1")"; _check "get $1"; cat "$RESP" ;; map) [ $# -ge 1 ] || usage _curl GET "$(_vault_url "$1")" -H 'Accept: application/vnd.olrapi.document-map+json' _check "map $1"; cat "$RESP" ;; ls) [ $# -ge 1 ] || usage p="$1"; [ "${p%/}" = "$p" ] && p="$p/" _curl GET "$(_vault_url "$p")"; _check "ls $1"; cat "$RESP" ;; search) [ $# -ge 1 ] || usage q="$*"; q="${q// /+}" _curl POST "${ECHO_BASE}/search/simple/?query=${q}"; _check "search"; cat "$RESP" ;; put) [ $# -ge 1 ] || usage path="$1"; _capture_body "${2:-}" _curl PUT "$(_vault_url "$path")" -H 'Content-Type: text/markdown' --data-binary @"$BODY" _check "put $path" if [ "$ECHO_VERIFY" = "1" ]; then _curl GET "$(_vault_url "$path")" [ "$HTTP" = "200" ] || die "put $path: write did not verify (GET returned $HTTP)" fi echo "ok: PUT $path" ;; post) [ $# -ge 1 ] || usage path="$1"; _capture_body "${2:-}" _curl POST "$(_vault_url "$path")" -H 'Content-Type: text/markdown' --data-binary @"$BODY" _check "post $path"; echo "ok: POST $path" ;; append) # idempotent: GET the file, skip the POST if the exact line is already present. [ $# -ge 2 ] || usage path="$1"; line="$2" _curl GET "$(_vault_url "$path")" if [ "$HTTP" = "200" ] && grep -qF -- "$line" "$RESP"; then echo "skip: line already present in $path"; exit 0 fi [ "$HTTP" = "200" ] || [ "$HTTP" = "404" ] || _check "append(read) $path" BODY="$(mktemp)"; printf '%s\n' "$line" > "$BODY" _curl POST "$(_vault_url "$path")" -H 'Content-Type: text/markdown' --data-binary @"$BODY" _check "append $path"; echo "ok: APPEND $path" ;; patch) [ $# -ge 4 ] || usage path="$1"; op="$2"; ttype="$3"; target="$4"; _capture_body "${5:-}" case "$op" in append|prepend|replace) ;; *) die "patch: op must be append|prepend|replace";; esac case "$ttype" in heading|frontmatter|block) ;; *) die "patch: target-type must be heading|frontmatter|block";; esac _curl PATCH "$(_vault_url "$path")" \ -H "Operation: $op" -H "Target-Type: $ttype" -H "Target: $target" \ -H 'Content-Type: text/markdown' --data-binary @"$BODY" _check "patch $path ($target)" echo "ok: PATCH $op $ttype '$target' -> $path" ;; fm) [ $# -ge 3 ] || usage path="$1"; field="$2"; value="$3" BODY="$(mktemp)"; printf '%s' "$value" > "$BODY" _curl PATCH "$(_vault_url "$path")" \ -H 'Operation: replace' -H 'Target-Type: frontmatter' -H "Target: $field" \ -H 'Content-Type: application/json' --data-binary @"$BODY" _check "fm $path.$field"; echo "ok: frontmatter $field -> $path" ;; bump) [ $# -ge 1 ] || usage path="$1"; d="${2:-$(_today)}" exec "$0" fm "$path" updated "\"$d\"" ;; delete) [ $# -ge 1 ] || usage _curl DELETE "$(_vault_url "$1")"; _check "delete $1"; echo "ok: DELETE $1" ;; lock) # advisory lock: _agent/locks/vault.lock holds " @ ". Honored cooperatively. [ $# -ge 1 ] || usage owner="$1"; lockpath="_agent/locks/vault.lock" _curl GET "$(_vault_url "$lockpath")" if [ "$HTTP" = "200" ] && [ -s "$RESP" ]; then cur="$(cat "$RESP")"; held_owner="${cur%% @ *}"; held_iso="${cur##* @ }"; held_iso="${held_iso%$'\n'}" held_epoch="$(date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$held_iso" +%s 2>/dev/null \ || date -u -d "$held_iso" +%s 2>/dev/null || echo 0)" now_epoch="$(date -u +%s)" if [ "$held_owner" != "$owner" ] && [ $((now_epoch - held_epoch)) -lt "$ECHO_LOCK_TTL" ]; then echo "lock held by '$held_owner' since $held_iso (fresh)" >&2; exit 75 fi fi BODY="$(mktemp)"; printf '%s @ %s\n' "$owner" "$(_now_iso)" > "$BODY" _curl PUT "$(_vault_url "$lockpath")" -H 'Content-Type: text/markdown' --data-binary @"$BODY" _check "lock"; echo "ok: locked by $owner" ;; unlock) [ $# -ge 1 ] || usage owner="$1"; lockpath="_agent/locks/vault.lock" _curl GET "$(_vault_url "$lockpath")" if [ "$HTTP" = "200" ]; then cur="$(cat "$RESP")"; held_owner="${cur%% @ *}" [ "$held_owner" = "$owner" ] || { echo "lock owned by '$held_owner', not '$owner' — not releasing" >&2; exit 75; } fi _curl DELETE "$(_vault_url "$lockpath")" [ "$HTTP" = "404" ] || _check "unlock" echo "ok: unlocked" ;; ""|-h|--help|help) usage ;; *) die "unknown command '$cmd' (try: get map ls search put post append patch fm bump delete lock unlock)" ;; esac