#!/bin/bash # ontoref — global entry point for the ontoref protocol CLI. # Release: 0.1.0 # # Discovers project root by walking up from CWD looking for .ontology/. # Fully self-contained: does NOT delegate to the dev repo. # # ONTOREF_ROOT is baked at install time to the data directory # (macOS: ~/Library/Application Support/ontoref, Linux: ~/.local/share/ontoref) # where reflection/ scripts are installed. # # Usage: # ontoref adr l # ontoref sync # ONTOREF_PROJECT_ROOT=/path/to/project ontoref adr l # explicit override set -euo pipefail _release() { grep "^# Release:" "$0" | sed "s/# Release: //g"; } # Baked at install time — replaced by install.nu with the actual data dir path. ONTOREF_ROOT="${ONTOREF_ROOT:-ontoref}" readonly DISPATCHER="${ONTOREF_ROOT}/reflection/bin/ontoref.nu" # ── Nushell check ───────────────────────────────────────────────────────────── if ! command -v nu &>/dev/null; then echo "" echo " ontoref requires Nushell (>= 0.110.0)" echo "" echo " Install:" echo " cargo install nu # via Rust" echo " brew install nushell # macOS" echo " winget install nushell # Windows" echo "" echo " Then re-run: ontoref $*" echo "" exit 1 fi # ── Version gate ────────────────────────────────────────────────────────────── NU_VERSION="$(nu --version 2>/dev/null | tr -d '[:space:]')" NU_MAJOR="${NU_VERSION%%.*}" NU_MAJOR="${NU_MAJOR%%[^0-9]*}" NU_MINOR="${NU_VERSION#*.}"; NU_MINOR="${NU_MINOR%%.*}" NU_MINOR="${NU_MINOR%%[^0-9]*}" if [[ "${NU_MAJOR}" -lt 0 ]] || { [[ "${NU_MAJOR}" -eq 0 ]] && [[ "${NU_MINOR}" -lt 110 ]]; }; then echo "" echo " Nushell ${NU_VERSION} found — 0.110.0+ required" echo " Update: cargo install nu --force" echo "" exit 1 fi # ── Guard: dispatcher must exist ────────────────────────────────────────────── if [[ ! -f "${DISPATCHER}" ]]; then echo "ontoref: reflection scripts not found at ${ONTOREF_ROOT}/reflection/" >&2 echo " Re-run the installer: nu install/install.nu" >&2 exit 1 fi # ── Version flag (early exit) ───────────────────────────────────────────────── for _arg in "$@"; do case "${_arg}" in -V|-v|--version|version) echo "ontoref $(_release)" exit 0 ;; esac done # ── Actor detection ──────────────────────────────────────────────────────────── ACTOR_FROM_ARGS="" ENV_ONLY=0 REMAINING_ARGS=() while [[ $# -gt 0 ]]; do case "$1" in --actor) shift ACTOR_FROM_ARGS="${1:-}" shift ;; --actor=*) ACTOR_FROM_ARGS="${1#--actor=}" shift ;; --env-only) ENV_ONLY=1 shift ;; *) REMAINING_ARGS+=("$1") shift ;; esac done if [[ -n "${ACTOR_FROM_ARGS}" ]]; then export ONTOREF_ACTOR="${ACTOR_FROM_ARGS}" elif [[ -n "${ONTOREF_ACTOR:-}" ]]; then : # already set by caller elif [[ -n "${CI:-}" ]] || [[ -n "${GITHUB_ACTIONS:-}" ]] || [[ -n "${WOODPECKER_BUILD_NUMBER:-}" ]]; then export ONTOREF_ACTOR="ci" elif [[ ! -t 0 ]]; then export ONTOREF_ACTOR="agent" else export ONTOREF_ACTOR="developer" fi # ── Project root discovery ───────────────────────────────────────────────────── _find_project_root() { local dir dir="$(pwd)" while [[ "${dir}" != "/" ]]; do [[ -d "${dir}/.ontology" ]] && echo "${dir}" && return 0 dir="$(dirname "${dir}")" done return 1 } if [[ -z "${ONTOREF_PROJECT_ROOT:-}" ]]; then if ! ONTOREF_PROJECT_ROOT="$(_find_project_root)"; then echo "ontoref: no .ontology/ found from $(pwd) up to /" >&2 echo " Run from within a project that has .ontology/, or set ONTOREF_PROJECT_ROOT." >&2 exit 1 fi fi export ONTOREF_ROOT export ONTOREF_PROJECT_ROOT readonly CONFIG_NCL="${ONTOREF_PROJECT_ROOT}/.ontoref/config.ncl" readonly LOCKS_DIR="${ONTOREF_PROJECT_ROOT}/.ontoref/locks" # ── Load project env vars (NICKEL_IMPORT_PATH) ──────────────────────────────── load_ontoref_env() { # Installed schemas dir provides ontoref-project.ncl and other protocol schemas. local installed_schemas="${HOME}/.config/ontoref/schemas" local fallback_paths="${ONTOREF_PROJECT_ROOT}:${ONTOREF_PROJECT_ROOT}/.ontology:${ONTOREF_PROJECT_ROOT}/adrs:${installed_schemas}:${ONTOREF_ROOT}" if [[ ! -f "${CONFIG_NCL}" ]]; then export NICKEL_IMPORT_PATH="${fallback_paths}" return 0 fi local raw_paths="" if command -v nickel &>/dev/null; then # nickel_import_paths from project config are relative to ONTOREF_PROJECT_ROOT. # shellcheck disable=SC2016 raw_paths="$(nickel export "${CONFIG_NCL}" 2>/dev/null | ONTOREF_ROOT="${ONTOREF_PROJECT_ROOT}" nu -c 'from json | get nickel_import_paths | each { |p| $env.ONTOREF_ROOT + "/" + $p } | str join ":"' 2>/dev/null)" || true fi if [[ -n "${raw_paths}" ]]; then export NICKEL_IMPORT_PATH="${raw_paths}:${installed_schemas}:${ONTOREF_ROOT}" else export NICKEL_IMPORT_PATH="${fallback_paths}" fi } if [[ -z "${NICKEL_IMPORT_PATH:-}" ]]; then load_ontoref_env fi # ── Advisory locking (mkdir-based, POSIX-atomic) ────────────────────────────── determine_lock() { local cmd="${REMAINING_ARGS[0]:-}" local sub="${REMAINING_ARGS[1]:-}" case "${cmd}" in config) case "${sub}" in apply|rollback) echo "manifest" ;; *) echo "" ;; esac ;; register) echo "changelog" ;; backlog) case "${sub}" in done|cancel) echo "backlog" ;; *) echo "" ;; esac ;; *) echo "" ;; esac } is_stale_lock() { local lockdir="$1" local owner_file="${lockdir}/owner" if [[ ! -f "${owner_file}" ]]; then return 0; fi local owner_pid owner_pid="$(cut -d: -f1 "${owner_file}" 2>/dev/null)" || return 0 if [[ -z "${owner_pid}" ]]; then return 0; fi if ! kill -0 "${owner_pid}" 2>/dev/null; then return 0; fi return 1 } ACQUIRED_LOCK="" acquire_lock() { local resource="$1" local timeout="${2:-30}" local lockdir="${LOCKS_DIR}/${resource}.lock" local elapsed=0 mkdir -p "${LOCKS_DIR}" local stale_retries=0 while ! mkdir "${lockdir}" 2>/dev/null; do local stale=0 # shellcheck disable=SC2310 is_stale_lock "${lockdir}" && stale=1 || true if [[ "${stale}" -eq 1 ]]; then stale_retries=$((stale_retries + 1)) if [[ "${stale_retries}" -gt 5 ]]; then echo " ontoref: stale lock on '${resource}' could not be cleared after 5 attempts" >&2 return 1 fi rm -rf "${lockdir}" sleep 0.1 continue fi if [[ "${elapsed}" -ge "${timeout}" ]]; then local owner="unknown" [[ -f "${lockdir}/owner" ]] && owner="$(cat "${lockdir}/owner")" echo " ontoref: lock timeout on '${resource}' after ${timeout}s (held by: ${owner})" >&2 return 1 fi sleep 1 elapsed=$(( elapsed + 1 )) done local now now="$(date -u +%Y%m%dT%H%M%SZ)" echo "$$:${ONTOREF_ACTOR}:${now}" > "${lockdir}/owner" ACQUIRED_LOCK="${lockdir}" } release_lock() { if [[ -n "${ACQUIRED_LOCK}" ]]; then rm -rf "${ACQUIRED_LOCK}" 2>/dev/null || true ACQUIRED_LOCK="" fi } # ── Export caller identity ───────────────────────────────────────────────────── export ONTOREF_CALLER="${ONTOREF_CALLER:-ontoref}" if [[ "${ENV_ONLY}" -eq 1 ]]; then # shellcheck disable=SC2317 return 0 2>/dev/null || exit 0 fi # ── No-args usage ───────────────────────────────────────────────────────────── if [[ "${#REMAINING_ARGS[@]}" -eq 0 ]]; then echo "" echo " ontoref" echo " Usage: ontoref [options]" echo "" echo " Use 'ontoref help' for available commands" echo "" exit 0 fi # ── Rewrite help flags ──────────────────────────────────────────────────────── _has_help=0 _non_help_args=() for _a in "${REMAINING_ARGS[@]+"${REMAINING_ARGS[@]}"}"; do case "${_a}" in --help|-help|-h) _has_help=1 ;; *) _non_help_args+=("${_a}") ;; esac done if [[ "${_has_help}" -eq 1 ]]; then if [[ "${#_non_help_args[@]}" -gt 0 ]]; then REMAINING_ARGS=("help" "${_non_help_args[@]}") else REMAINING_ARGS=("help") fi fi # ── Fix trailing flags that require a value ──────────────────────────────────── if [[ "${#REMAINING_ARGS[@]}" -gt 0 ]]; then _last="${REMAINING_ARGS[${#REMAINING_ARGS[@]}-1]}" # shellcheck disable=SC2249 case "${_last}" in --fmt|--format|-fmt|-f|--actor|--context|--severity|--backend|--kind|--priority|--status) REMAINING_ARGS+=("select") ;; esac fi # ── Delegate to Nushell dispatcher ──────────────────────────────────────────── LOCK_RESOURCE="$(determine_lock)" if [[ -n "${LOCK_RESOURCE}" ]]; then acquire_lock "${LOCK_RESOURCE}" 30 trap 'release_lock' EXIT INT TERM nu "${DISPATCHER}" "${REMAINING_ARGS[@]+"${REMAINING_ARGS[@]}"}" else nu "${DISPATCHER}" "${REMAINING_ARGS[@]+"${REMAINING_ARGS[@]}"}" fi