Files
2026-06-19 23:03:06 -05:00

246 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# goldbrain.sh — the single validated client for the goldbrain 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):
# GB_BASE default https://goldbrainapi.mpm.to
# GB_KEY default the plugin bearer token
# GB_VERIFY default 1 — read-back verify after a PUT
# GB_LOCK_TTL default 900 — seconds before an advisory lock is considered stale
#
# Usage:
# goldbrain.sh get <path> # print file contents (404 -> exit 44)
# goldbrain.sh map <path> # document-map JSON (headings/blocks/frontmatter)
# goldbrain.sh ls <dir> # directory listing JSON
# goldbrain.sh search <query...> # /search/simple
# goldbrain.sh put <path> [file] # create/overwrite (body from file or stdin)
# goldbrain.sh post <path> [file] # raw append (NON-idempotent; prefer `append`)
# goldbrain.sh append <path> <line> # idempotent append: skips if the exact line exists
# goldbrain.sh patch <path> <append|prepend|replace> <heading|frontmatter|block> <target> [file]
# goldbrain.sh fm <path> <field> <json-value> # PATCH a frontmatter scalar (e.g. fm p.md updated '"2026-06-19"')
# goldbrain.sh bump <path> [YYYY-MM-DD] # set frontmatter updated: to today (or given date)
# goldbrain.sh delete <path> # DELETE (destructive; explicit use only)
# goldbrain.sh lock <owner-id> # acquire advisory lock (exit 75 if held by someone else & fresh)
# goldbrain.sh unlock <owner-id> # release advisory lock if owned by <owner-id>
# goldbrain.sh scope show # print active scope, its freshness, and sessions-since
# goldbrain.sh scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
#
# Exit codes: 0 ok · 44 not-found(404) · 75 lock-held · 2 usage · 1 other HTTP/transport error.
set -euo pipefail
GB_BASE="${GB_BASE:-https://goldbrainapi.mpm.to}"
GB_BASE="${GB_BASE%/}"
GB_KEY="${GB_KEY:-fb72065a05fabb28ae87c45880cc3b7aba4fd3f58e70297934145cef974e8ed8}"
GB_VERIFY="${GB_VERIFY:-1}"
GB_LOCK_TTL="${GB_LOCK_TTL:-900}"
AUTH="Authorization: Bearer ${GB_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 "goldbrain.sh: $*" >&2; exit 1; }
usage() { sed -n '2,40p' "$0" >&2; exit 2; }
_today() { echo "${GB_TODAY:-$(date +%Y-%m-%d)}"; }
_now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
_vault_url() { echo "${GB_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) [$GB_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 "${GB_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 [ "$GB_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 "$GB_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" ;;
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 ;;
""|-h|--help|help) usage ;;
*) die "unknown command '$cmd' (try: get map ls search put post append patch fm bump delete lock unlock scope)" ;;
esac