lib.sh 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. #!/usr/bin/env bash
  2. #
  3. # lib.sh — shared pure-bash helpers for scripts/convert.sh and scripts/install.sh.
  4. #
  5. # No external dependencies. Bash 3.2+ compatible (macOS ships 3.2).
  6. # Sourced, not executed. Groups:
  7. # 1. Frontmatter / slug helpers (agent data model)
  8. # 2. set -e-safe primitives
  9. # 3. Terminal capability + ANSI (color, unicode, sizing)
  10. # 4. TUI primitives (raw input, alt-screen, flicker-free draw)
  11. #
  12. # Everything here is namespaced loosely and guarded so it is safe to source
  13. # from a script already running under `set -euo pipefail`.
  14. # ---------------------------------------------------------------------------
  15. # 1. Frontmatter / slug helpers
  16. # ---------------------------------------------------------------------------
  17. # get_field <field> <file> — value of a YAML frontmatter field (first match).
  18. get_field() {
  19. local field="$1" file="$2"
  20. awk -v f="$field" '
  21. /^---$/ { fm++; next }
  22. fm == 1 && $0 ~ "^" f ": " { sub("^" f ": ", ""); print; exit }
  23. ' "$file"
  24. }
  25. # get_body <file> — file contents with the leading frontmatter block stripped.
  26. get_body() {
  27. awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$1"
  28. }
  29. # slugify <string> — "Frontend Developer" -> "frontend-developer"
  30. slugify() {
  31. printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
  32. | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//'
  33. }
  34. # agent_slug <file> — slug derived from the file's `name:` frontmatter.
  35. # Single source of truth so convert + install always agree.
  36. agent_slug() {
  37. local name; name="$(get_field name "$1")"
  38. [[ -n "$name" ]] && slugify "$name"
  39. }
  40. # is_agent_file <file> — true if the file starts with a YAML frontmatter fence.
  41. is_agent_file() {
  42. [[ -f "$1" ]] && [[ "$(head -1 "$1")" == "---" ]]
  43. }
  44. # ---------------------------------------------------------------------------
  45. # 2. set -e-safe primitives (absorbs #505 — no more `(( x++ )) || true`)
  46. # ---------------------------------------------------------------------------
  47. # incr <varname> — increment a numeric variable in place, safely under set -e.
  48. incr() { printf -v "$1" '%d' "$(( ${!1:-0} + 1 ))"; }
  49. # ---------------------------------------------------------------------------
  50. # 3. Terminal capability + ANSI
  51. # ---------------------------------------------------------------------------
  52. supports_color() { [[ -t 1 && -z "${NO_COLOR:-}" && "${TERM:-}" != "dumb" ]]; }
  53. supports_unicode() { [[ "${LANG:-}${LC_ALL:-}${LC_CTYPE:-}" == *[Uu][Tt][Ff]* ]]; }
  54. term_cols() { local c; c="$(tput cols 2>/dev/null)"; [[ "$c" =~ ^[0-9]+$ ]] && echo "$c" || echo 80; }
  55. term_rows() { local r; r="$(tput lines 2>/dev/null)"; [[ "$r" =~ ^[0-9]+$ ]] && echo "$r" || echo 24; }
  56. # init_ansi — populate C_* color vars + box-drawing chars (UTF-8 or ASCII).
  57. init_ansi() {
  58. if supports_color; then
  59. C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'; C_REV=$'\033[7m'
  60. C_RED=$'\033[0;31m'; C_GREEN=$'\033[0;32m'; C_YELLOW=$'\033[1;33m'
  61. C_BLUE=$'\033[0;34m'; C_CYAN=$'\033[0;36m'; C_MAGENTA=$'\033[0;35m'
  62. else
  63. C_RESET=''; C_BOLD=''; C_DIM=''; C_REV=''
  64. C_RED=''; C_GREEN=''; C_YELLOW=''; C_BLUE=''; C_CYAN=''; C_MAGENTA=''
  65. fi
  66. if supports_unicode; then
  67. BX_TL='╭'; BX_TR='╮'; BX_BL='╰'; BX_BR='╯'; BX_H='─'; BX_V='│'
  68. GLYPH_ON='✓'; GLYPH_DET='●'; GLYPH_OFF='○'; GLYPH_CUR='▸'
  69. else
  70. BX_TL='+'; BX_TR='+'; BX_BL='+'; BX_BR='+'; BX_H='-'; BX_V='|'
  71. GLYPH_ON='x'; GLYPH_DET='*'; GLYPH_OFF=' '; GLYPH_CUR='>'
  72. fi
  73. }
  74. # repeat <char> <n> — print <char> n times.
  75. repeat() { local i; for (( i=0; i<$2; i++ )); do printf '%s' "$1"; done; }
  76. # strip_ansi <string> — remove ANSI escape sequences (for width math).
  77. strip_ansi() { printf '%s' "$1" | sed $'s/\033\\[[0-9;]*m//g'; }
  78. # vis_len <string> — visible length (ANSI-stripped). Note: assumes 1 col/char.
  79. vis_len() { local s; s="$(strip_ansi "$1")"; printf '%s' "${#s}"; }
  80. # ---------------------------------------------------------------------------
  81. # 4. TUI primitives (used by install.sh's interactive wizard)
  82. # ---------------------------------------------------------------------------
  83. _TUI_ACTIVE=0
  84. _TUI_STTY_SAVE=""
  85. # tui_begin — enter alt screen, hide cursor, raw mode; install restore trap.
  86. tui_begin() {
  87. # Test hook: drive the wizard from piped keystrokes (skips the TTY gate and
  88. # the alt-screen/stty takeover). Used by the install-script test harness.
  89. [[ -n "${AGENCY_TUI_FORCE:-}" ]] && { _TUI_ACTIVE=1; return 0; }
  90. [[ -t 0 && -t 1 ]] || return 1
  91. _TUI_STTY_SAVE="$(stty -g 2>/dev/null)" || return 1
  92. stty -echo -icanon time 0 min 1 2>/dev/null || return 1
  93. printf '\033[?1049h\033[?25l' # alt screen + hide cursor
  94. _TUI_ACTIVE=1
  95. trap 'tui_end' EXIT INT TERM
  96. }
  97. # tui_end — restore terminal (idempotent; safe from trap).
  98. tui_end() {
  99. [[ "$_TUI_ACTIVE" == "1" ]] || return 0
  100. printf '\033[?25h\033[?1049l' # show cursor + leave alt screen
  101. [[ -n "$_TUI_STTY_SAVE" ]] && stty "$_TUI_STTY_SAVE" 2>/dev/null
  102. _TUI_ACTIVE=0
  103. trap - EXIT INT TERM
  104. }
  105. # read_key — read one keypress, echo a normalized token:
  106. # UP DOWN LEFT RIGHT ENTER SPACE ESC BACKSPACE TAB or the literal character.
  107. #
  108. # Reads escape sequences byte-by-byte with INTEGER timeouts (bash 3.2 has no
  109. # fractional -t). A real arrow sends ESC [ A (or ESC O A in application-cursor
  110. # mode) as one buffered burst, so the follow-up reads return instantly; only a
  111. # lone Esc waits out the 1s timeout. Handles both CSI ('[') and SS3 ('O').
  112. read_key() {
  113. local k k2 k3
  114. IFS= read -rsn1 k 2>/dev/null || { printf 'EOF'; return; }
  115. case "$k" in
  116. $'\033')
  117. if ! IFS= read -rsn1 -t 1 k2 2>/dev/null; then printf 'ESC'; return; fi
  118. if [[ "$k2" == '[' || "$k2" == 'O' ]]; then
  119. IFS= read -rsn1 -t 1 k3 2>/dev/null
  120. case "$k3" in
  121. A) printf 'UP' ;; B) printf 'DOWN' ;;
  122. C) printf 'RIGHT' ;; D) printf 'LEFT' ;;
  123. *) printf 'ESC' ;;
  124. esac
  125. else
  126. printf 'ESC'
  127. fi ;;
  128. $'\n'|$'\r'|'') printf 'ENTER' ;; # Enter is CR in raw mode (sometimes empty)
  129. ' ') printf 'SPACE' ;;
  130. $'\t') printf 'TAB' ;;
  131. $'\177'|$'\010') printf 'BACKSPACE' ;;
  132. *) printf '%s' "$k" ;;
  133. esac
  134. }
  135. # draw_frame <buffer> — home cursor and paint a pre-composed frame.
  136. # Flicker-free: erase-to-end-of-line (\033[K) on every line so a shorter new
  137. # line never leaves the previous frame's tail behind, then erase-to-end-of-
  138. # screen (\033[0J) to drop any leftover lines below the frame.
  139. draw_frame() {
  140. local buf="${1//$'\n'/$'\033[K'$'\n'}"
  141. printf '\033[H%s\033[K\033[0J' "$buf"
  142. }