#!/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" # $ONTOREF_ROOT/ontology is included so consumer projects can resolve: # import "defaults/state.ncl" → $ONTOREF_ROOT/ontology/defaults/state.ncl # import "schemas/manifest.ncl" → $ONTOREF_ROOT/ontology/schemas/manifest.ncl local ontoref_ontology="${ONTOREF_ROOT}/ontology" local fallback_paths="${ONTOREF_PROJECT_ROOT}:${ONTOREF_PROJECT_ROOT}/.ontology:${ONTOREF_PROJECT_ROOT}/adrs:${installed_schemas}:${ontoref_ontology}:${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_ontology}:${ONTOREF_ROOT}" else export NICKEL_IMPORT_PATH="${fallback_paths}" fi } if [[ -z "${NICKEL_IMPORT_PATH:-}" ]]; then load_ontoref_env fi # ── Export ONTOREF_DOMAIN_ROOT from domain_origin.path ──────────────────────── # If the project declares domain_origin in its manifest, expose the framework path # so NCL extension files can import from: import "%{env.ONTOREF_DOMAIN_ROOT}/.ontology/..." # Uses grep (no nickel penalty) — path line expected to be a simple string literal. if [[ -z "${ONTOREF_DOMAIN_ROOT:-}" ]]; then _manifest="${ONTOREF_PROJECT_ROOT}/.ontology/manifest.ncl" if grep -q "domain_origin" "${_manifest}" 2>/dev/null; then _domain_path="$(grep -A5 "domain_origin" "${_manifest}" | grep 'path\s*=' | head -1 | sed 's/.*=\s*"\(.*\)".*/\1/')" if [[ -n "${_domain_path}" && -d "${_domain_path}" ]]; then export ONTOREF_DOMAIN_ROOT="${_domain_path}" fi unset _domain_path fi unset _manifest 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 # ── Normalize --fmt/-f: extract from any position and append after subcommand ─ _fmt_val="" _no_fmt_args=() _fi=0 while [[ $_fi -lt ${#REMAINING_ARGS[@]} ]]; do _a="${REMAINING_ARGS[$_fi]}" case "${_a}" in --fmt|-f|--format|-fmt) _fi=$(( _fi + 1 )) _fmt_val="${REMAINING_ARGS[$_fi]:-}" ;; --fmt=*|--format=*) _fmt_val="${_a#*=}" ;; *) _no_fmt_args+=("${_a}") ;; esac _fi=$(( _fi + 1 )) done if [[ -n "${_fmt_val}" ]]; then REMAINING_ARGS=("${_no_fmt_args[@]+"${_no_fmt_args[@]}"}" "--fmt" "${_fmt_val}") 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|--actor|--context|--severity|--backend|--kind|--priority|--status) REMAINING_ARGS+=("select") ;; esac fi # ── Universal --clip: capture stdout, strip ANSI, copy to clipboard ─────────── _has_clip=0 _no_clip_args=() for _a in "${REMAINING_ARGS[@]+"${REMAINING_ARGS[@]}"}"; do case "${_a}" in --clip|-c) _has_clip=1 ;; *) _no_clip_args+=("${_a}") ;; esac done _strip_ansi() { sed $'s/\033\\[[0-9;]*[mGKHFJABCDEFM]//g'; } _copy_to_clipboard() { if command -v pbcopy &>/dev/null; then printf '%s' "${1}" | pbcopy elif command -v xclip &>/dev/null; then printf '%s' "${1}" | xclip -selection clipboard elif command -v wl-copy &>/dev/null; then printf '%s' "${1}" | wl-copy else echo " No clipboard tool found (install pbcopy, xclip, or wl-copy)" >&2 return 1 fi echo " ✓ Copied to clipboard" >&2 } # ── Domain extension dispatch ───────────────────────────────────────────────── # If the first arg matches a domain id and the project's repo_kind is in that # domain's repo_kinds.txt, delegate to the domain's commands.nu directly. _dispatch_domain() { local first_arg="${REMAINING_ARGS[0]:-}" [[ -z "$first_arg" ]] && return 1 # Resolve short alias → domain id (e.g. prov → provisioning, jpl → personal). local aliases_file="${ONTOREF_ROOT}/domains/aliases.txt" if [[ -f "$aliases_file" ]]; then local resolved resolved="$(grep "^${first_arg}=" "$aliases_file" 2>/dev/null | cut -d= -f2)" || true if [[ -n "$resolved" ]]; then first_arg="$resolved" REMAINING_ARGS=("$first_arg" "${REMAINING_ARGS[@]:1}") fi fi local domain_dir="${ONTOREF_ROOT}/domains/${first_arg}" # Not a known domain ID or alias — fall through to Nu dispatcher as normal command. [[ ! -d "$domain_dir" ]] && return 1 # From here on, the arg IS a domain name. All failures become diagnostics, not fall-throughs. local repo_kinds_file="${domain_dir}/repo_kinds.txt" if [[ ! -f "$repo_kinds_file" ]]; then echo "ontoref: domain '${first_arg}' is missing repo_kinds.txt — reinstall ontoref" >&2 exit 1 fi local manifest="${ONTOREF_PROJECT_ROOT}/.ontology/manifest.ncl" if [[ ! -f "$manifest" ]]; then echo "" >&2 echo " ontoref: domain '${first_arg}' requires a project with .ontology/manifest.ncl" >&2 echo " current project: ${ONTOREF_PROJECT_ROOT}" >&2 echo "" >&2 exit 1 fi # Extract repo_kind directly from the NCL source — no nickel export needed. # The field is always a literal enum tag: repo_kind = 'SomeName, # This avoids import-path resolution failures for manifests that import schemas. local repo_kind="" repo_kind="$(grep -oE "repo_kind\s*=\s*'[A-Za-z_]+" "$manifest" 2>/dev/null | grep -oE "'[A-Za-z_]+$" | tr -d "'")" || true if [[ -z "$repo_kind" ]]; then echo "" >&2 echo " ontoref: domain '${first_arg}' is not available for this project" >&2 echo " reason: repo_kind is not set in ${manifest}" >&2 echo "" >&2 exit 1 fi if ! grep -qx "$repo_kind" "$repo_kinds_file" 2>/dev/null; then local required required="$(tr '\n' ' ' < "$repo_kinds_file" | sed 's/ $//')" echo "" >&2 echo " ontoref: domain '${first_arg}' is not available for this project" >&2 echo " requires repo_kind: ${required}" >&2 echo " current repo_kind: ${repo_kind}" >&2 echo "" >&2 exit 1 fi local domain_cmd="${domain_dir}/commands.nu" if [[ ! -f "$domain_cmd" ]]; then echo "ontoref: domain '${first_arg}' commands.nu not found — reinstall ontoref" >&2 exit 1 fi local domain_args=("${REMAINING_ARGS[@]:1}") nu "$domain_cmd" "${domain_args[@]+"${domain_args[@]}"}" return 0 } if _dispatch_domain; then exit 0 fi # ── Delegate to Nushell dispatcher ──────────────────────────────────────────── LOCK_RESOURCE="$(determine_lock)" # --clip strategy: # Structured --fmt (json/yaml/toml/md): non-interactive subprocess capture via stdin redirect. # Text (no --fmt or --fmt text): pass --clip to Nushell — it handles clipboard after selection. _fmt_is_structured=0 case "${_fmt_val}" in json|yaml|toml|md|j|y|t|m) _fmt_is_structured=1 ;; esac if [[ "${_has_clip}" -eq 1 ]] && [[ "${_fmt_is_structured}" -eq 1 ]]; then if [[ -n "${LOCK_RESOURCE}" ]]; then acquire_lock "${LOCK_RESOURCE}" 30 trap 'release_lock' EXIT INT TERM fi _captured="$(nu "${DISPATCHER}" "${_no_clip_args[@]+"${_no_clip_args[@]}"}" 2>&1 < /dev/null | _strip_ansi)" printf '%s\n' "${_captured}" _copy_to_clipboard "${_captured}" elif [[ "${_has_clip}" -eq 1 ]]; then if [[ -n "${LOCK_RESOURCE}" ]]; then acquire_lock "${LOCK_RESOURCE}" 30 trap 'release_lock' EXIT INT TERM fi # Text mode: pass --clip through; Nushell copies after interactive selection. nu "${DISPATCHER}" "${_no_clip_args[@]+"${_no_clip_args[@]}"}" "--clip" else if [[ -n "${LOCK_RESOURCE}" ]]; then acquire_lock "${LOCK_RESOURCE}" 30 trap 'release_lock' EXIT INT TERM fi nu "${DISPATCHER}" "${REMAINING_ARGS[@]+"${REMAINING_ARGS[@]}"}" fi