2026-06-19 21:12:14 -05:00
#!/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 <path> # print file contents (404 -> exit 44)
# echo.sh map <path> # document-map JSON (headings/blocks/frontmatter)
# echo.sh ls <dir> # directory listing JSON
# echo.sh search <query...> # /search/simple
# echo.sh put <path> [file] # create/overwrite (body from file or stdin)
# echo.sh post <path> [file] # raw append (NON-idempotent; prefer `append`)
# echo.sh append <path> <line> # idempotent append: skips if the exact line exists
# echo.sh patch <path> <append|prepend|replace> <heading|frontmatter|block> <target> [file]
# echo.sh fm <path> <field> <json-value> # PATCH a frontmatter scalar (e.g. fm p.md updated '"2026-06-19"')
# echo.sh bump <path> [YYYY-MM-DD] # set frontmatter updated: to today (or given date)
# echo.sh delete <path> # DELETE (destructive; explicit use only)
# echo.sh lock <owner-id> # acquire advisory lock (exit 75 if held by someone else & fresh)
# echo.sh unlock <owner-id> # release advisory lock if owned by <owner-id>
2026-06-19 21:57:41 -05:00
# echo.sh scope show # print active scope, its freshness, and sessions-since
# echo.sh scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
2026-06-19 21:12:14 -05:00
#
# 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 "<owner> @ <iso>". 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" ;;
2026-06-19 21:57:41 -05:00
scope)
# scope show | scope set "<new scope text>"
# 'set' archives the prior scope to ## Scope History, replaces ## Scope, and stamps
# the scope_updated freshness timestamp — one command instead of three error-prone PATCHes.
sub = " ${ 1 :- show } " ; shift || true
ccpath = "_agent/context/current-context.md"
case " $sub " in
show)
_curl GET " $( _vault_url " $ccpath " ) " ; _check "scope show"
cur = " $RESP "
echo "── Active scope ──"
awk '/^## Scope[[:space:]]*$/{f=1;next} /^## /{if(f)exit} f' " $cur "
su = " $( sed -n 's/^scope_updated:[[:space:]]*//p' " $cur " | head -1) "
su = " ${ su // \" / } "
echo "scope_updated: ${ su :- <missing — drift cannot be detected; run scope set or repair> } "
_curl GET " $( _vault_url "_agent/sessions/" ) "
if [ " $HTTP " = "200" ] && [ -n " $su " ] ; then
n = " $( python3 -c "import json,sys;f=json.load(open(' $RESP '))['files'];print(sum(1 for x in f if x.endswith('.md') and x[:10]>' $su '))" 2>/dev/null || echo '?' ) "
echo "sessions logged since: ${ n } "
fi ;;
set )
[ $# -ge 1 ] || die "scope set needs the new scope text"
new = " $1 "
_curl GET " $( _vault_url " $ccpath " ) " ; _check "scope set(read)"
prior = " $( awk '/^## Scope[[:space:]]*$/{f=1;next} /^## /{if(f)exit} f' " $RESP " \
| tr '\n' ' ' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | cut -c1-140) "
BODY = " $( mktemp) " ; printf -- '- %s: %s\n' " $( _today) " " ${ prior :- (prior scope) } " > " $BODY "
_curl PATCH " $( _vault_url " $ccpath " ) " -H 'Operation: prepend' -H 'Target-Type: heading' \
-H 'Target: Current Context::Scope History' -H 'Content-Type: text/markdown' --data-binary @" $BODY "
_check "scope set(history)"
BODY = " $( mktemp) " ; printf '%s\n' " $new " > " $BODY "
_curl PATCH " $( _vault_url " $ccpath " ) " -H 'Operation: replace' -H 'Target-Type: heading' \
-H 'Target: Current Context::Scope' -H 'Content-Type: text/markdown' --data-binary @" $BODY "
_check "scope set(replace)"
BODY = " $( mktemp) " ; printf '"%s"' " $( _today) " > " $BODY "
_curl PATCH " $( _vault_url " $ccpath " ) " -H 'Operation: replace' -H 'Target-Type: frontmatter' \
-H 'Target: scope_updated' -H 'Content-Type: application/json' --data-binary @" $BODY "
if [ " ${ HTTP :- 000 } " -ge 400 ] ; then
die "scope set: body switched, but scope_updated frontmatter is missing (run bootstrap.sh repair to add it) [HTTP $HTTP ]"
fi
echo "ok: scope switched (prior archived to Scope History; scope_updated= $( _today) )" ;;
*) die "scope: use 'show' or 'set \"<text>\"'" ;;
esac ;;
2026-06-19 21:12:14 -05:00
"" | -h| --help| help ) usage ;;
2026-06-19 21:57:41 -05:00
*) die "unknown command ' $cmd ' (try: get map ls search put post append patch fm bump delete lock unlock scope)" ;;
2026-06-19 21:12:14 -05:00
esac