246 lines
11 KiB
Bash
Executable File
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
|