From 3ea9b413da3c377acdc9f6977aebbf9a697a9134 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Thu, 26 Mar 2026 22:47:50 +0000 Subject: [PATCH 01/11] Add contrib mode and rename 'check' to 'needs'. Discover and parse CONTRIBUTING.md, DEVELOPMENT.md, DEVELOPERS.md, and HACKING.md alongside the README. Contributor docs appear in all modes with a file separator, and `hdi contrib` filters to only those files. Rename the `check|c` subcommand to `needs|n`, freeing the `c` alias for contrib. --- README.md | 6 +- build | 2 +- hdi | 323 +++++++++++++-------- src/args.sh | 6 +- src/display.sh | 10 + src/header.sh | 3 +- src/json.sh | 21 +- src/main.sh | 23 +- src/{check.sh => needs.sh} | 6 +- src/parse.sh | 5 + src/picker.sh | 12 +- src/readme.sh | 26 +- src/render.sh | 19 ++ test/fixtures/node-express/CONTRIBUTING.md | 40 +++ test/hdi.bats | 96 ++++-- 15 files changed, 435 insertions(+), 163 deletions(-) rename src/{check.sh => needs.sh} (93%) create mode 100644 test/fixtures/node-express/CONTRIBUTING.md diff --git a/README.md b/README.md index d040f8c..298f42e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,8 @@ hdi run Just run/start commands (aliases: start, r) hdi test Just test commands (alias: t) hdi deploy Just deploy/release commands and platform detection (alias: d) hdi all All sections (aliases: a) -hdi check Check if required tools are installed (alias: c) +hdi contrib Commands from contributor/development docs (alias: c) +hdi needs Check if required tools are installed (alias: n) hdi /path/to/project Scan a different directory hdi /path/to/file.md Parse a specific markdown file ``` @@ -78,7 +79,8 @@ hdi r Run/start commands hdi t Test commands hdi d Deploy/release commands hdi a All sections -hdi c Check required tools +hdi c Contributor/development docs +hdi n Check required tools ``` ### Flags diff --git a/build b/build index 8eeaff1..b4d4a71 100755 --- a/build +++ b/build @@ -16,8 +16,8 @@ sources=( src/display.sh src/render.sh src/picker.sh - src/check.sh src/platform.sh + src/needs.sh src/json.sh src/main.sh ) diff --git a/hdi b/hdi index 7e321d0..83e70dc 100755 --- a/hdi +++ b/hdi @@ -8,7 +8,8 @@ # hdi test Just test commands (alias: t) # hdi deploy Just deploy/release commands and platform detection (alias: d) # hdi all Show all matched sections (currently the default mode) -# hdi check Check if required tools are installed (experimental) +# hdi contrib Show commands from contributor/development docs (alias: c) +# hdi needs Check if required tools are installed (alias: n) # hdi [mode] --no-interactive Print commands without the picker (alias: --ni) # hdi [mode] --full Include prose around commands # hdi [mode] --raw Plain markdown output (no colour, good for piping) @@ -52,7 +53,8 @@ for arg in "$@"; do test|t) MODE="test" ;; deploy|d) MODE="deploy" ;; all|a) MODE="all" ;; - check|c) MODE="check" ;; + needs|n) MODE="needs" ;; + contrib|c) MODE="contrib" ;; --full|-f) FULL=true ;; --raw) RAW=true; INTERACTIVE="no" ;; --json) JSON=true; INTERACTIVE="no" ;; @@ -98,7 +100,8 @@ case "$MODE" in test) PATTERN="($KW_TEST)" ;; deploy) PATTERN="($KW_DEPLOY)" ;; all) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; - check) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; + needs) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; + contrib) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; default) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; esac @@ -125,16 +128,42 @@ else done fi -if [[ -z "$README" ]]; then +if [[ -z "$README" ]] && [[ "$MODE" != "contrib" ]]; then echo "${YELLOW}hdi: no README found in ${DIR}${RESET}" >&2 echo "${DIM}Looked for README.md, readme.md, Readme.md, README.rst${RESET}" >&2 echo "${DIM}Try: hdi --help${RESET}" >&2 exit 1 fi +# ── Discover contributor/development docs ─────────────────────────────────── +CONTRIB_FILES=() +if [[ -z "$FILE" ]]; then + for _cname in CONTRIBUTING.md contributing.md Contributing.md \ + DEVELOPMENT.md development.md Development.md \ + DEVELOPERS.md developers.md Developers.md \ + HACKING.md hacking.md; do + [[ -f "$DIR/$_cname" ]] || continue + # Deduplicate (case-insensitive filesystems may match multiple variants) + _cf_dup=false + _cf_real=$(cd "$DIR" && realpath "$_cname" 2>/dev/null || echo "$DIR/$_cname") + for _cf_existing in "${CONTRIB_FILES[@]+"${CONTRIB_FILES[@]}"}"; do + [[ "$_cf_existing" == "$_cf_real" ]] && _cf_dup=true && break + done + $_cf_dup || CONTRIB_FILES+=("$_cf_real") + done +fi + +if [[ "$MODE" == "contrib" ]] && (( ${#CONTRIB_FILES[@]} == 0 )); then + echo "${YELLOW}hdi: no contributor docs found in ${DIR}${RESET}" >&2 + echo "${DIM}Looked for CONTRIBUTING.md, DEVELOPMENT.md, DEVELOPERS.md, HACKING.md${RESET}" >&2 + exit 1 +fi + # ── Extract matching sections ──────────────────────────────────────────────── declare -a SECTION_TITLES=() declare -a SECTION_BODIES=() +declare -a SECTION_FILES=() +_PARSE_SOURCE="" parse_sections() { local in_section=false @@ -172,12 +201,14 @@ parse_sections() { if (( level <= section_level )); then SECTION_TITLES+=("$heading_text") SECTION_BODIES+=("$body") + SECTION_FILES+=("$_PARSE_SOURCE") in_section=false body="" elif [[ "$text" =~ $PATTERN ]]; then # Deeper child heading also matches - save parent body first SECTION_TITLES+=("$heading_text") SECTION_BODIES+=("$body") + SECTION_FILES+=("$_PARSE_SOURCE") in_section=false body="" fi @@ -297,6 +328,7 @@ parse_sections() { if $in_section; then SECTION_TITLES+=("$heading_text") SECTION_BODIES+=("$body") + SECTION_FILES+=("$_PARSE_SOURCE") fi shopt -u nocasematch @@ -532,9 +564,19 @@ declare -a CMD_INDICES=() # indices into DISPLAY_LINES that are commands declare -a SECTION_FIRST_CMD=() # cursor indices (into CMD_INDICES) of first cmd per section build_display_list() { + local _prev_source="" for i in "${!SECTION_TITLES[@]}"; do local title="${SECTION_TITLES[$i]}" local body="${SECTION_BODIES[$i]}" + local _source="${SECTION_FILES[$i]:-}" + + # File separator when source file changes + if [[ -n "$_prev_source" && -n "$_source" && "$_source" != "$_prev_source" ]]; then + DISPLAY_LINES+=("$(basename "$_source")") + LINE_TYPES+=("filesep") + LINE_CMDS+=("") + fi + _prev_source="$_source" # Section header DISPLAY_LINES+=("$title") @@ -643,6 +685,13 @@ render_static() { local type="${LINE_TYPES[$idx]}" case "$type" in + filesep) + if $RAW; then + printf "\n--- %s ---\n" "$line" + else + printf "\n%s ── %s ──────────────────────────────%s\n" "$DIM" "$line" "$RESET" + fi + ;; header) if $RAW; then printf "\n## %s\n" "$line" @@ -677,9 +726,21 @@ render_static() { # ── Full-prose render ──────────────────────────────────────────────────────── render_full() { + local _rf_prev_source="" for i in "${!SECTION_TITLES[@]}"; do local title="${SECTION_TITLES[$i]}" local content="${SECTION_BODIES[$i]}" + local _rf_source="${SECTION_FILES[$i]:-}" + + # File separator when source changes + if [[ -n "$_rf_prev_source" && -n "$_rf_source" && "$_rf_source" != "$_rf_prev_source" ]]; then + if $RAW; then + printf "\n--- %s ---\n" "$(basename "$_rf_source")" + else + printf "\n%s ── %s ──────────────────────────────%s\n" "$DIM" "$(basename "$_rf_source")" "$RESET" + fi + fi + _rf_prev_source="$_rf_source" # Strip trailing blank lines (pure bash, no tail subprocess) while [[ "$content" == *$'\n' ]]; do @@ -810,7 +871,7 @@ _term_height() { # Headers/subheaders take 2 lines (blank + text), others take 1 _SL=1 _sl() { - case "${LINE_TYPES[$1]}" in header|subheader) _SL=2 ;; *) _SL=1 ;; esac + case "${LINE_TYPES[$1]}" in header|subheader|filesep) _SL=2 ;; *) _SL=1 ;; esac } # Adjust VIEWPORT_TOP so that the selected item is visible @@ -842,7 +903,7 @@ adjust_viewport() { # Walk back through consecutive headers/subheaders to show full context while (( VIEWPORT_TOP > 0 )); do local prev_type="${LINE_TYPES[$((VIEWPORT_TOP - 1))]}" - if [[ "$prev_type" == "header" || "$prev_type" == "subheader" ]]; then + if [[ "$prev_type" == "header" || "$prev_type" == "subheader" || "$prev_type" == "filesep" ]]; then (( VIEWPORT_TOP -= 1 )) else break @@ -943,6 +1004,7 @@ draw_picker() { fi ;; all) hdr+=" ${DIM}[all]${RESET}" ;; + contrib) hdr+=" ${DIM}[contrib]${RESET}" ;; esac _line "$hdr" local chrome=3 @@ -982,6 +1044,13 @@ draw_picker() { fi case "$type" in + filesep) + if (( idx != VIEWPORT_TOP )); then + _blank; (( rendered += 1 )) + fi + _line " ${DIM}── ${line} ──────────────────────────────${RESET}" + (( rendered += 1 )) + ;; header) if (( idx != VIEWPORT_TOP )); then _blank; (( rendered += 1 )) @@ -1247,105 +1316,6 @@ run_interactive() { done } -# ── Check mode: report which tools are installed ───────────────────────────── - -# Shell builtins and coreutils that are always available - not worth checking -_CHECK_SKIP="^(cd|cp|mv|rm|mkdir|echo|export|source|cat|chmod|chown|ln|touch|ls|printf|trap|pwd|set|unset|eval|exec|exit|return|read|test|true|false|tee|head|tail|wc|sort|grep|xargs|find|tar|gzip|gunzip|sed|awk|tr|cut|diff|date|sleep|kill|whoami|env|which|man|less|more)$" - -# Extract the tool name from a command string -# Strips leading env vars (FOO=bar) and sudo, returns the first word -# Sets _CT_RESULT or "" if nothing useful -_check_tool_name() { - local cmd="$1" - - # Strip leading env vars - while [[ "$cmd" =~ ^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]* ]]; do - cmd="${cmd#"${BASH_REMATCH[0]}"}" - cmd="${cmd#"${cmd%%[![:space:]]*}"}" - done - - # Strip sudo - if [[ "$cmd" =~ ^sudo[[:space:]]+ ]]; then - cmd="${cmd#sudo}" - cmd="${cmd#"${cmd%%[![:space:]]*}"}" - fi - - # First word - local tool="${cmd%% *}" - - # Skip paths (./foo, /foo, bin/foo), flags (-h, --help), empty, builtins - if [[ -z "$tool" ]] || [[ "$tool" == -* ]] || [[ "$tool" == */* ]] || [[ "$tool" =~ $_CHECK_SKIP ]]; then - _CT_RESULT="" - return - fi - - _CT_RESULT="$tool" -} - -run_check() { - local -a tools=() - local tool - - # Collect unique tool names from all extracted commands - for idx in "${!DISPLAY_LINES[@]}"; do - [[ "${LINE_TYPES[$idx]}" != "command" ]] && continue - _check_tool_name "${LINE_CMDS[$idx]}" - [[ -z "$_CT_RESULT" ]] && continue - tool="$_CT_RESULT" - - # Deduplicate - local seen=false - for t in "${tools[@]+"${tools[@]}"}"; do - [[ "$t" == "$tool" ]] && seen=true && break - done - $seen && continue - tools+=("$tool") - done - - if (( ${#tools[@]} == 0 )); then - echo "${YELLOW}hdi: no tool references found in commands${RESET}" >&2 - echo "${DIM}Try: hdi all --full${RESET}" >&2 - exit 1 - fi - - # Header - printf "\n%s%s[hdi] %s%s %scheck (experimental)%s\n\n" "$BOLD" "$YELLOW" "$PROJECT_NAME" "$RESET" "$DIM" "$RESET" - - local found=0 missing=0 - for tool in "${tools[@]}"; do - if command -v "$tool" >/dev/null 2>&1; then - # Try to extract a version number (some tools use -V instead of --version) - local ver="" raw="" - raw=$("$tool" --version 2>&1 | head -5) || true - if [[ "$raw" =~ [0-9]+\.[0-9]+[0-9.]* ]]; then - ver="${BASH_REMATCH[0]}" - else - raw=$("$tool" -V 2>&1 | head -5) || true - if [[ "$raw" =~ [0-9]+\.[0-9]+[0-9.]* ]]; then - ver="${BASH_REMATCH[0]}" - fi - fi - - if [[ -n "$ver" ]]; then - printf " %s✓%s %-14s %s(%s)%s\n" "$GREEN" "$RESET" "$tool" "$DIM" "$ver" "$RESET" - else - printf " %s✓%s %-14s\n" "$GREEN" "$RESET" "$tool" - fi - found=$((found + 1)) - else - printf " %s✗%s %-14s %snot found%s\n" "$YELLOW" "$RESET" "$tool" "$DIM" "$RESET" - missing=$((missing + 1)) - fi - done - - printf "\n" - if (( missing == 0 )); then - printf " %s✓ All %d tools found%s\n\n" "$DIM" "$found" "$RESET" - else - printf " %s%d found, %s%d not found%s\n\n" "$DIM" "$found" "$YELLOW" "$missing" "$RESET" - fi -} - # ── Platform detection ──────────────────────────────────────────────────────── # Detects deployment platforms from three sources: # 1. Config files in the project directory (high confidence) @@ -1484,6 +1454,105 @@ build_platform_display() { _PLATFORM_DISPLAY="$parts" } +# ── Needs mode: report which tools are installed ───────────────────────────── + +# Shell builtins and coreutils that are always available - not worth checking +_CHECK_SKIP="^(cd|cp|mv|rm|mkdir|echo|export|source|cat|chmod|chown|ln|touch|ls|printf|trap|pwd|set|unset|eval|exec|exit|return|read|test|true|false|tee|head|tail|wc|sort|grep|xargs|find|tar|gzip|gunzip|sed|awk|tr|cut|diff|date|sleep|kill|whoami|env|which|man|less|more)$" + +# Extract the tool name from a command string +# Strips leading env vars (FOO=bar) and sudo, returns the first word +# Sets _CT_RESULT or "" if nothing useful +_check_tool_name() { + local cmd="$1" + + # Strip leading env vars + while [[ "$cmd" =~ ^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]* ]]; do + cmd="${cmd#"${BASH_REMATCH[0]}"}" + cmd="${cmd#"${cmd%%[![:space:]]*}"}" + done + + # Strip sudo + if [[ "$cmd" =~ ^sudo[[:space:]]+ ]]; then + cmd="${cmd#sudo}" + cmd="${cmd#"${cmd%%[![:space:]]*}"}" + fi + + # First word + local tool="${cmd%% *}" + + # Skip paths (./foo, /foo, bin/foo), flags (-h, --help), empty, builtins + if [[ -z "$tool" ]] || [[ "$tool" == -* ]] || [[ "$tool" == */* ]] || [[ "$tool" =~ $_CHECK_SKIP ]]; then + _CT_RESULT="" + return + fi + + _CT_RESULT="$tool" +} + +run_needs() { + local -a tools=() + local tool + + # Collect unique tool names from all extracted commands + for idx in "${!DISPLAY_LINES[@]}"; do + [[ "${LINE_TYPES[$idx]}" != "command" ]] && continue + _check_tool_name "${LINE_CMDS[$idx]}" + [[ -z "$_CT_RESULT" ]] && continue + tool="$_CT_RESULT" + + # Deduplicate + local seen=false + for t in "${tools[@]+"${tools[@]}"}"; do + [[ "$t" == "$tool" ]] && seen=true && break + done + $seen && continue + tools+=("$tool") + done + + if (( ${#tools[@]} == 0 )); then + echo "${YELLOW}hdi: no tool references found in commands${RESET}" >&2 + echo "${DIM}Try: hdi all --full${RESET}" >&2 + exit 1 + fi + + # Header + printf "\n%s%s[hdi] %s%s %sneeds%s\n\n" "$BOLD" "$YELLOW" "$PROJECT_NAME" "$RESET" "$DIM" "$RESET" + + local found=0 missing=0 + for tool in "${tools[@]}"; do + if command -v "$tool" >/dev/null 2>&1; then + # Try to extract a version number (some tools use -V instead of --version) + local ver="" raw="" + raw=$("$tool" --version 2>&1 | head -5) || true + if [[ "$raw" =~ [0-9]+\.[0-9]+[0-9.]* ]]; then + ver="${BASH_REMATCH[0]}" + else + raw=$("$tool" -V 2>&1 | head -5) || true + if [[ "$raw" =~ [0-9]+\.[0-9]+[0-9.]* ]]; then + ver="${BASH_REMATCH[0]}" + fi + fi + + if [[ -n "$ver" ]]; then + printf " %s✓%s %-14s %s(%s)%s\n" "$GREEN" "$RESET" "$tool" "$DIM" "$ver" "$RESET" + else + printf " %s✓%s %-14s\n" "$GREEN" "$RESET" "$tool" + fi + found=$((found + 1)) + else + printf " %s✗%s %-14s %snot found%s\n" "$YELLOW" "$RESET" "$tool" "$DIM" "$RESET" + missing=$((missing + 1)) + fi + done + + printf "\n" + if (( missing == 0 )); then + printf " %s✓ All %d tools found%s\n\n" "$DIM" "$found" "$RESET" + else + printf " %s%d found, %s%d not found%s\n\n" "$DIM" "$found" "$YELLOW" "$missing" "$RESET" + fi +} + # ── JSON output ─────────────────────────────────────────────────────────────── # Escape a string for safe embedding in JSON @@ -1647,8 +1716,8 @@ _json_full_prose() { printf '\n ]' } -# Print the check array as JSON using the current DISPLAY_LINES -_json_check() { +# Print the needs array as JSON using the current DISPLAY_LINES +_json_needs() { local -a tools=() local tool @@ -1705,7 +1774,7 @@ _json_platforms() { fi } -# Main JSON renderer: outputs all modes, fullProse, and check +# Main JSON renderer: outputs all modes, fullProse, and needs render_json() { local _modes=("default" "install" "run" "test" "deploy" "all") local first @@ -1716,7 +1785,8 @@ render_json() { $first || printf ',\n' first=false _json_set_pattern "$_m" - SECTION_TITLES=(); SECTION_BODIES=() + SECTION_TITLES=(); SECTION_BODIES=(); SECTION_FILES=() + _PARSE_SOURCE="$README" parse_sections < "$README" DISPLAY_LINES=(); LINE_TYPES=(); LINE_CMDS=(); CMD_INDICES=() build_display_list @@ -1729,19 +1799,21 @@ render_json() { $first || printf ',\n' first=false _json_set_pattern "$_m" - SECTION_TITLES=(); SECTION_BODIES=() + SECTION_TITLES=(); SECTION_BODIES=(); SECTION_FILES=() + _PARSE_SOURCE="$README" parse_sections < "$README" _json_full_prose "$_m" done - # Re-parse with "all" pattern for check tool extraction - printf '\n },\n "check": ' + # Re-parse with "all" pattern for needs tool extraction + printf '\n },\n "needs": ' _json_set_pattern "all" - SECTION_TITLES=(); SECTION_BODIES=() + SECTION_TITLES=(); SECTION_BODIES=(); SECTION_FILES=() + _PARSE_SOURCE="$README" parse_sections < "$README" DISPLAY_LINES=(); LINE_TYPES=(); LINE_CMDS=(); CMD_INDICES=() build_display_list - _json_check + _json_needs # Platform detection (uses deploy pattern) printf ',\n "platforms": ' @@ -1769,10 +1841,24 @@ if $JSON; then exit 0 fi -parse_sections < "$README" +# Parse README (unless contrib-only mode) +if [[ "$MODE" != "contrib" ]] && [[ -n "$README" ]]; then + _PARSE_SOURCE="$README" + parse_sections < "$README" +fi + +# Parse contributor docs +for _cf in "${CONTRIB_FILES[@]+"${CONTRIB_FILES[@]}"}"; do + _PARSE_SOURCE="$_cf" + parse_sections < "$_cf" +done if (( ${#SECTION_TITLES[@]} == 0 )); then - echo "${YELLOW}hdi: no matching sections found in ${README}${RESET}" >&2 + if [[ "$MODE" == "contrib" ]]; then + echo "${YELLOW}hdi: no matching sections found in contributor docs${RESET}" >&2 + else + echo "${YELLOW}hdi: no matching sections found in ${README}${RESET}" >&2 + fi echo "${DIM}Try: hdi all --full${RESET}" >&2 exit 1 fi @@ -1790,8 +1876,8 @@ if [[ "$MODE" == "deploy" ]]; then build_platform_display fi -if [[ "$MODE" == "check" ]]; then - run_check +if [[ "$MODE" == "needs" ]]; then + run_needs elif [[ "$INTERACTIVE" == "yes" ]] && ! $FULL; then run_interactive else @@ -1809,6 +1895,7 @@ else fi ;; all) printf " %s[all]%s" "$DIM" "$RESET" ;; + contrib) printf " %s[contrib]%s" "$DIM" "$RESET" ;; esac printf "\n" fi diff --git a/src/args.sh b/src/args.sh index 50d5a9d..7476416 100644 --- a/src/args.sh +++ b/src/args.sh @@ -22,7 +22,8 @@ for arg in "$@"; do test|t) MODE="test" ;; deploy|d) MODE="deploy" ;; all|a) MODE="all" ;; - check|c) MODE="check" ;; + needs|n) MODE="needs" ;; + contrib|c) MODE="contrib" ;; --full|-f) FULL=true ;; --raw) RAW=true; INTERACTIVE="no" ;; --json) JSON=true; INTERACTIVE="no" ;; @@ -68,6 +69,7 @@ case "$MODE" in test) PATTERN="($KW_TEST)" ;; deploy) PATTERN="($KW_DEPLOY)" ;; all) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; - check) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; + needs) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; + contrib) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; default) PATTERN="($KW_INSTALL|$KW_RUN|$KW_TEST|$KW_DEPLOY|$KW_EXTRA)" ;; esac diff --git a/src/display.sh b/src/display.sh index c7e2518..fd1f44d 100644 --- a/src/display.sh +++ b/src/display.sh @@ -10,9 +10,19 @@ declare -a CMD_INDICES=() # indices into DISPLAY_LINES that are commands declare -a SECTION_FIRST_CMD=() # cursor indices (into CMD_INDICES) of first cmd per section build_display_list() { + local _prev_source="" for i in "${!SECTION_TITLES[@]}"; do local title="${SECTION_TITLES[$i]}" local body="${SECTION_BODIES[$i]}" + local _source="${SECTION_FILES[$i]:-}" + + # File separator when source file changes + if [[ -n "$_prev_source" && -n "$_source" && "$_source" != "$_prev_source" ]]; then + DISPLAY_LINES+=("$(basename "$_source")") + LINE_TYPES+=("filesep") + LINE_CMDS+=("") + fi + _prev_source="$_source" # Section header DISPLAY_LINES+=("$title") diff --git a/src/header.sh b/src/header.sh index cc0b5d7..e44617f 100644 --- a/src/header.sh +++ b/src/header.sh @@ -8,7 +8,8 @@ # hdi test Just test commands (alias: t) # hdi deploy Just deploy/release commands and platform detection (alias: d) # hdi all Show all matched sections (currently the default mode) -# hdi check Check if required tools are installed (experimental) +# hdi contrib Show commands from contributor/development docs (alias: c) +# hdi needs Check if required tools are installed (alias: n) # hdi [mode] --no-interactive Print commands without the picker (alias: --ni) # hdi [mode] --full Include prose around commands # hdi [mode] --raw Plain markdown output (no colour, good for piping) diff --git a/src/json.sh b/src/json.sh index 469a1c3..312102d 100644 --- a/src/json.sh +++ b/src/json.sh @@ -161,8 +161,8 @@ _json_full_prose() { printf '\n ]' } -# Print the check array as JSON using the current DISPLAY_LINES -_json_check() { +# Print the needs array as JSON using the current DISPLAY_LINES +_json_needs() { local -a tools=() local tool @@ -219,7 +219,7 @@ _json_platforms() { fi } -# Main JSON renderer: outputs all modes, fullProse, and check +# Main JSON renderer: outputs all modes, fullProse, and needs render_json() { local _modes=("default" "install" "run" "test" "deploy" "all") local first @@ -230,7 +230,8 @@ render_json() { $first || printf ',\n' first=false _json_set_pattern "$_m" - SECTION_TITLES=(); SECTION_BODIES=() + SECTION_TITLES=(); SECTION_BODIES=(); SECTION_FILES=() + _PARSE_SOURCE="$README" parse_sections < "$README" DISPLAY_LINES=(); LINE_TYPES=(); LINE_CMDS=(); CMD_INDICES=() build_display_list @@ -243,19 +244,21 @@ render_json() { $first || printf ',\n' first=false _json_set_pattern "$_m" - SECTION_TITLES=(); SECTION_BODIES=() + SECTION_TITLES=(); SECTION_BODIES=(); SECTION_FILES=() + _PARSE_SOURCE="$README" parse_sections < "$README" _json_full_prose "$_m" done - # Re-parse with "all" pattern for check tool extraction - printf '\n },\n "check": ' + # Re-parse with "all" pattern for needs tool extraction + printf '\n },\n "needs": ' _json_set_pattern "all" - SECTION_TITLES=(); SECTION_BODIES=() + SECTION_TITLES=(); SECTION_BODIES=(); SECTION_FILES=() + _PARSE_SOURCE="$README" parse_sections < "$README" DISPLAY_LINES=(); LINE_TYPES=(); LINE_CMDS=(); CMD_INDICES=() build_display_list - _json_check + _json_needs # Platform detection (uses deploy pattern) printf ',\n "platforms": ' diff --git a/src/main.sh b/src/main.sh index cb3e0b7..c4d65f0 100644 --- a/src/main.sh +++ b/src/main.sh @@ -5,10 +5,24 @@ if $JSON; then exit 0 fi -parse_sections < "$README" +# Parse README (unless contrib-only mode) +if [[ "$MODE" != "contrib" ]] && [[ -n "$README" ]]; then + _PARSE_SOURCE="$README" + parse_sections < "$README" +fi + +# Parse contributor docs +for _cf in "${CONTRIB_FILES[@]+"${CONTRIB_FILES[@]}"}"; do + _PARSE_SOURCE="$_cf" + parse_sections < "$_cf" +done if (( ${#SECTION_TITLES[@]} == 0 )); then - echo "${YELLOW}hdi: no matching sections found in ${README}${RESET}" >&2 + if [[ "$MODE" == "contrib" ]]; then + echo "${YELLOW}hdi: no matching sections found in contributor docs${RESET}" >&2 + else + echo "${YELLOW}hdi: no matching sections found in ${README}${RESET}" >&2 + fi echo "${DIM}Try: hdi all --full${RESET}" >&2 exit 1 fi @@ -26,8 +40,8 @@ if [[ "$MODE" == "deploy" ]]; then build_platform_display fi -if [[ "$MODE" == "check" ]]; then - run_check +if [[ "$MODE" == "needs" ]]; then + run_needs elif [[ "$INTERACTIVE" == "yes" ]] && ! $FULL; then run_interactive else @@ -45,6 +59,7 @@ else fi ;; all) printf " %s[all]%s" "$DIM" "$RESET" ;; + contrib) printf " %s[contrib]%s" "$DIM" "$RESET" ;; esac printf "\n" fi diff --git a/src/check.sh b/src/needs.sh similarity index 93% rename from src/check.sh rename to src/needs.sh index 48bcf0c..5fc4cc7 100644 --- a/src/check.sh +++ b/src/needs.sh @@ -1,4 +1,4 @@ -# ── Check mode: report which tools are installed ───────────────────────────── +# ── Needs mode: report which tools are installed ───────────────────────────── # Shell builtins and coreutils that are always available - not worth checking _CHECK_SKIP="^(cd|cp|mv|rm|mkdir|echo|export|source|cat|chmod|chown|ln|touch|ls|printf|trap|pwd|set|unset|eval|exec|exit|return|read|test|true|false|tee|head|tail|wc|sort|grep|xargs|find|tar|gzip|gunzip|sed|awk|tr|cut|diff|date|sleep|kill|whoami|env|which|man|less|more)$" @@ -33,7 +33,7 @@ _check_tool_name() { _CT_RESULT="$tool" } -run_check() { +run_needs() { local -a tools=() local tool @@ -60,7 +60,7 @@ run_check() { fi # Header - printf "\n%s%s[hdi] %s%s %scheck (experimental)%s\n\n" "$BOLD" "$YELLOW" "$PROJECT_NAME" "$RESET" "$DIM" "$RESET" + printf "\n%s%s[hdi] %s%s %sneeds%s\n\n" "$BOLD" "$YELLOW" "$PROJECT_NAME" "$RESET" "$DIM" "$RESET" local found=0 missing=0 for tool in "${tools[@]}"; do diff --git a/src/parse.sh b/src/parse.sh index f82b764..c442854 100644 --- a/src/parse.sh +++ b/src/parse.sh @@ -1,6 +1,8 @@ # ── Extract matching sections ──────────────────────────────────────────────── declare -a SECTION_TITLES=() declare -a SECTION_BODIES=() +declare -a SECTION_FILES=() +_PARSE_SOURCE="" parse_sections() { local in_section=false @@ -38,12 +40,14 @@ parse_sections() { if (( level <= section_level )); then SECTION_TITLES+=("$heading_text") SECTION_BODIES+=("$body") + SECTION_FILES+=("$_PARSE_SOURCE") in_section=false body="" elif [[ "$text" =~ $PATTERN ]]; then # Deeper child heading also matches - save parent body first SECTION_TITLES+=("$heading_text") SECTION_BODIES+=("$body") + SECTION_FILES+=("$_PARSE_SOURCE") in_section=false body="" fi @@ -163,6 +167,7 @@ parse_sections() { if $in_section; then SECTION_TITLES+=("$heading_text") SECTION_BODIES+=("$body") + SECTION_FILES+=("$_PARSE_SOURCE") fi shopt -u nocasematch diff --git a/src/picker.sh b/src/picker.sh index d638257..171b3d4 100644 --- a/src/picker.sh +++ b/src/picker.sh @@ -23,7 +23,7 @@ _term_height() { # Headers/subheaders take 2 lines (blank + text), others take 1 _SL=1 _sl() { - case "${LINE_TYPES[$1]}" in header|subheader) _SL=2 ;; *) _SL=1 ;; esac + case "${LINE_TYPES[$1]}" in header|subheader|filesep) _SL=2 ;; *) _SL=1 ;; esac } # Adjust VIEWPORT_TOP so that the selected item is visible @@ -55,7 +55,7 @@ adjust_viewport() { # Walk back through consecutive headers/subheaders to show full context while (( VIEWPORT_TOP > 0 )); do local prev_type="${LINE_TYPES[$((VIEWPORT_TOP - 1))]}" - if [[ "$prev_type" == "header" || "$prev_type" == "subheader" ]]; then + if [[ "$prev_type" == "header" || "$prev_type" == "subheader" || "$prev_type" == "filesep" ]]; then (( VIEWPORT_TOP -= 1 )) else break @@ -156,6 +156,7 @@ draw_picker() { fi ;; all) hdr+=" ${DIM}[all]${RESET}" ;; + contrib) hdr+=" ${DIM}[contrib]${RESET}" ;; esac _line "$hdr" local chrome=3 @@ -195,6 +196,13 @@ draw_picker() { fi case "$type" in + filesep) + if (( idx != VIEWPORT_TOP )); then + _blank; (( rendered += 1 )) + fi + _line " ${DIM}── ${line} ──────────────────────────────${RESET}" + (( rendered += 1 )) + ;; header) if (( idx != VIEWPORT_TOP )); then _blank; (( rendered += 1 )) diff --git a/src/readme.sh b/src/readme.sh index 3767bee..3b8aac5 100644 --- a/src/readme.sh +++ b/src/readme.sh @@ -9,9 +9,33 @@ else done fi -if [[ -z "$README" ]]; then +if [[ -z "$README" ]] && [[ "$MODE" != "contrib" ]]; then echo "${YELLOW}hdi: no README found in ${DIR}${RESET}" >&2 echo "${DIM}Looked for README.md, readme.md, Readme.md, README.rst${RESET}" >&2 echo "${DIM}Try: hdi --help${RESET}" >&2 exit 1 fi + +# ── Discover contributor/development docs ─────────────────────────────────── +CONTRIB_FILES=() +if [[ -z "$FILE" ]]; then + for _cname in CONTRIBUTING.md contributing.md Contributing.md \ + DEVELOPMENT.md development.md Development.md \ + DEVELOPERS.md developers.md Developers.md \ + HACKING.md hacking.md; do + [[ -f "$DIR/$_cname" ]] || continue + # Deduplicate (case-insensitive filesystems may match multiple variants) + _cf_dup=false + _cf_real=$(cd "$DIR" && realpath "$_cname" 2>/dev/null || echo "$DIR/$_cname") + for _cf_existing in "${CONTRIB_FILES[@]+"${CONTRIB_FILES[@]}"}"; do + [[ "$_cf_existing" == "$_cf_real" ]] && _cf_dup=true && break + done + $_cf_dup || CONTRIB_FILES+=("$_cf_real") + done +fi + +if [[ "$MODE" == "contrib" ]] && (( ${#CONTRIB_FILES[@]} == 0 )); then + echo "${YELLOW}hdi: no contributor docs found in ${DIR}${RESET}" >&2 + echo "${DIM}Looked for CONTRIBUTING.md, DEVELOPMENT.md, DEVELOPERS.md, HACKING.md${RESET}" >&2 + exit 1 +fi diff --git a/src/render.sh b/src/render.sh index c05bbf6..bfa0aca 100644 --- a/src/render.sh +++ b/src/render.sh @@ -5,6 +5,13 @@ render_static() { local type="${LINE_TYPES[$idx]}" case "$type" in + filesep) + if $RAW; then + printf "\n--- %s ---\n" "$line" + else + printf "\n%s ── %s ──────────────────────────────%s\n" "$DIM" "$line" "$RESET" + fi + ;; header) if $RAW; then printf "\n## %s\n" "$line" @@ -39,9 +46,21 @@ render_static() { # ── Full-prose render ──────────────────────────────────────────────────────── render_full() { + local _rf_prev_source="" for i in "${!SECTION_TITLES[@]}"; do local title="${SECTION_TITLES[$i]}" local content="${SECTION_BODIES[$i]}" + local _rf_source="${SECTION_FILES[$i]:-}" + + # File separator when source changes + if [[ -n "$_rf_prev_source" && -n "$_rf_source" && "$_rf_source" != "$_rf_prev_source" ]]; then + if $RAW; then + printf "\n--- %s ---\n" "$(basename "$_rf_source")" + else + printf "\n%s ── %s ──────────────────────────────%s\n" "$DIM" "$(basename "$_rf_source")" "$RESET" + fi + fi + _rf_prev_source="$_rf_source" # Strip trailing blank lines (pure bash, no tail subprocess) while [[ "$content" == *$'\n' ]]; do diff --git a/test/fixtures/node-express/CONTRIBUTING.md b/test/fixtures/node-express/CONTRIBUTING.md new file mode 100644 index 0000000..252497a --- /dev/null +++ b/test/fixtures/node-express/CONTRIBUTING.md @@ -0,0 +1,40 @@ +# Contributing to express-api + +## Development Setup + +Fork the repo and install dependencies: + +```bash +npm install +cp .env.example .env.test +``` + +## Running Tests + +Run the full test suite with coverage: + +```bash +npm run test:coverage +``` + +Run integration tests only: + +```bash +npm run test:integration +``` + +## Code Style + +We use ESLint and Prettier. Run the linter before submitting a PR: + +```bash +npm run lint +npm run format +``` + +## Release Process + +```bash +npm version patch +npm publish +``` diff --git a/test/hdi.bats b/test/hdi.bats index f2e1dc7..8dc3b3b 100644 --- a/test/hdi.bats +++ b/test/hdi.bats @@ -1359,30 +1359,30 @@ else: [[ "$output" == *"npm run serve --env staging"* ]] } -# ── Check mode ────────────────────────────────────────────────────────────── +# ── Needs mode ────────────────────────────────────────────────────────────── -@test "check: reports installed tools" { - run "$HDI" check "$FIXTURES/node-express" +@test "needs: reports installed tools" { + run "$HDI" needs "$FIXTURES/node-express" [ "$status" -eq 0 ] [[ "$output" == *"npm"* ]] } -@test "check: marks missing tools" { - run "$HDI" check "$FIXTURES/node-express" +@test "needs: marks missing tools" { + run "$HDI" needs "$FIXTURES/node-express" [ "$status" -eq 0 ] [[ "$output" == *"nvm"* ]] [[ "$output" == *"not found"* ]] } -@test "check: skips shell builtins like cp" { - run "$HDI" check "$FIXTURES/node-express" +@test "needs: skips shell builtins like cp" { + run "$HDI" needs "$FIXTURES/node-express" [ "$status" -eq 0 ] - # cp is in the install section but should not appear in check output + # cp is in the install section but should not appear in needs output [[ "$output" != *" cp "* ]] } -@test "check: deduplicates tool names" { - run "$HDI" check "$FIXTURES/node-express" +@test "needs: deduplicates tool names" { + run "$HDI" needs "$FIXTURES/node-express" [ "$status" -eq 0 ] # npm appears in multiple commands but should only be listed once local count @@ -1390,22 +1390,22 @@ else: [ "$count" -eq 1 ] } -@test "check: scans all sections (install + run + test)" { - run "$HDI" check "$FIXTURES/react-nextjs" +@test "needs: scans all sections (install + run + test)" { + run "$HDI" needs "$FIXTURES/react-nextjs" [ "$status" -eq 0 ] [[ "$output" == *"npm"* ]] } -@test "check: skips path-like commands" { - run "$HDI" check "$FIXTURES/ruby-rails" +@test "needs: skips path-like commands" { + run "$HDI" needs "$FIXTURES/ruby-rails" [ "$status" -eq 0 ] [[ "$output" != *"bin/rails"* ]] [[ "$output" != *"bin/dev"* ]] } -@test "check: skips flags in code blocks" { +@test "needs: skips flags in code blocks" { # Flags like -h, --help, --raw should not appear as tools - run "$HDI" check "$BATS_TEST_DIRNAME/.." + run "$HDI" needs "$BATS_TEST_DIRNAME/.." [ "$status" -eq 0 ] [[ "$output" != *" -h,"* ]] [[ "$output" != *" -v,"* ]] @@ -1414,6 +1414,62 @@ else: [[ "$output" != *" --ni,"* ]] } +# ── Contrib mode ─────────────────────────────────────────────────────────── + +@test "contrib: shows commands from CONTRIBUTING.md" { + run "$HDI" contrib "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" == *"npm run test:coverage"* ]] + [[ "$output" == *"npm version patch"* ]] +} + +@test "contrib: 'c' is an alias for contrib mode" { + run "$HDI" c "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" == *"npm run test:coverage"* ]] +} + +@test "contrib: does not include README commands" { + run "$HDI" contrib "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" != *"npx prisma migrate dev"* ]] +} + +@test "contrib: error when no contributor docs found" { + run "$HDI" contrib "$FIXTURES/python-flask" + [ "$status" -eq 1 ] + [[ "$output" == *"no contributor docs found"* ]] +} + +@test "contrib: default mode includes contrib with separator" { + run "$HDI" --ni "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" == *"CONTRIBUTING.md"* ]] + [[ "$output" == *"npm run test:coverage"* ]] + [[ "$output" == *"npm install"* ]] +} + +@test "contrib: --raw separator uses plain text" { + run "$HDI" --raw "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" == *"--- CONTRIBUTING.md ---"* ]] +} + +@test "contrib: mode filter applies to contrib sections" { + run "$HDI" test "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" == *"npm test"* ]] + [[ "$output" == *"npm run test:coverage"* ]] + # lint/format are not test commands + [[ "$output" != *"npm run lint"* ]] +} + +@test "contrib: no separator when no contrib files" { + run "$HDI" --ni "$FIXTURES/python-flask" + [ "$status" -eq 0 ] + [[ "$output" != *"CONTRIBUTING"* ]] +} + # ── JSON output ──────────────────────────────────────────────────────────── @test "json: produces valid JSON" { @@ -1438,10 +1494,10 @@ else: done } -@test "json: contains check array" { +@test "json: contains needs array" { run "$HDI" --json "$FIXTURES/node-express" [ "$status" -eq 0 ] - echo "$output" | python3 -c "import json,sys; d=json.load(sys.stdin); assert isinstance(d['check'], list)" + echo "$output" | python3 -c "import json,sys; d=json.load(sys.stdin); assert isinstance(d['needs'], list)" } @test "json: modes items have type and text fields" { @@ -1481,13 +1537,13 @@ assert 'header' in types, 'no header items' " } -@test "json: check items have tool and installed fields" { +@test "json: needs items have tool and installed fields" { run "$HDI" --json "$FIXTURES/node-express" [ "$status" -eq 0 ] echo "$output" | python3 -c " import json,sys d=json.load(sys.stdin) -for item in d['check']: +for item in d['needs']: assert 'tool' in item and 'installed' in item, f'missing fields in {item}' " } From 76a177701612e1e80eb2f960f6d332a8ec7e78fc Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Wed, 1 Apr 2026 20:26:56 +0100 Subject: [PATCH 02/11] More clearly separate additional files, visually eg. when rendering CONTRIBUTING.md under the README.md content, needed clearly delineation --- hdi | 34 ++++++++++++++++++++++++---------- src/picker.sh | 30 ++++++++++++++++++++++-------- src/render.sh | 4 ++-- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/hdi b/hdi index 0ca0a04..f988d16 100755 --- a/hdi +++ b/hdi @@ -693,7 +693,7 @@ render_static() { if $RAW; then printf "\n--- %s ---\n" "$line" else - printf "\n%s ── %s ──────────────────────────────%s\n" "$DIM" "$line" "$RESET" + _file_separator "$line"; printf "\n%s\n\n" "$_FS" fi ;; header) @@ -741,7 +741,7 @@ render_full() { if $RAW; then printf "\n--- %s ---\n" "$(basename "$_rf_source")" else - printf "\n%s ── %s ──────────────────────────────%s\n" "$DIM" "$(basename "$_rf_source")" "$RESET" + _file_separator "$(basename "$_rf_source")"; printf "\n%s\n\n" "$_FS" fi fi _rf_prev_source="$_rf_source" @@ -871,16 +871,18 @@ _term_height() { echo 24 } -# Get terminal width reliably (stty from tty, fallback to tput, then 80) +# Get terminal width reliably (COLUMNS, stty from tty, fallback to tput, then 80) _term_width() { + if (( ${COLUMNS:-0} > 0 )); then echo "$COLUMNS"; return; fi local w w=$(stty size < /dev/tty 2>/dev/null) && w="${w##* }" && (( w > 0 )) && { echo "$w"; return; } w=$(tput cols 2>/dev/null) && (( w > 0 )) && { echo "$w"; return; } echo 80 } -# Pre-computed dash string (200 chars covers any reasonable terminal width) +# Pre-computed dash strings (200 chars covers any reasonable terminal width) _DASH_POOL="────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────" +_DOUBLE_DASH_POOL="════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════" # Format a section header with trailing dashes (sets _SH, no subshell) # _RENDER_WIDTH defaults to _MAX_CONTENT_WIDTH; draw_picker caps it to terminal width @@ -896,11 +898,23 @@ _section_header() { _SH="${BOLD}${CYAN}${prefix}${RESET}${DIM}${_DASH_POOL:0:n}${RESET}" } +# Format a file separator with double-line dashes (sets _FS, no subshell) +_FS="" +_file_separator() { + (( _RENDER_WIDTH == 0 )) && _RENDER_WIDTH=$_MAX_CONTENT_WIDTH + local prefix=" ══ $1 " + local prefix_len=${#prefix} + local target=$(( _RENDER_WIDTH > prefix_len ? _RENDER_WIDTH : prefix_len + 4 )) + local n=$(( target - prefix_len )) + (( n < 2 )) && n=2 + _FS="${DIM} ══ ${RESET}${BOLD}${YELLOW}$1${RESET}${DIM} ${_DOUBLE_DASH_POOL:0:n}${RESET}" +} + # Screen lines per display entry type — sets _SL (no subshell) # Headers/subheaders take 2 lines (blank + text), others take 1 _SL=1 _sl() { - case "${LINE_TYPES[$1]}" in header|subheader|filesep) _SL=2 ;; *) _SL=1 ;; esac + case "${LINE_TYPES[$1]}" in filesep) _SL=3 ;; header|subheader) _SL=2 ;; *) _SL=1 ;; esac } # Adjust VIEWPORT_TOP so that the selected item is visible @@ -920,7 +934,7 @@ adjust_viewport() { for (( _i = 0; _i < n_items; _i++ )); do _sl "$_i"; (( total += _SL )) done - case "${LINE_TYPES[0]}" in header|subheader) (( total -= 1 )) ;; esac + case "${LINE_TYPES[0]}" in header|subheader|filesep) (( total -= 1 )) ;; esac if (( total + chrome <= term_h )); then VIEWPORT_TOP=0 return @@ -955,7 +969,7 @@ adjust_viewport() { _sl "$idx"; lines=$_SL # First item at viewport_top has blank line suppressed in draw_picker if (( idx == VIEWPORT_TOP )); then - case "${LINE_TYPES[$idx]}" in header|subheader) (( lines -= 1 )) ;; esac + case "${LINE_TYPES[$idx]}" in header|subheader|filesep) (( lines -= 1 )) ;; esac fi if (( idx == selected )); then if (( row + lines > avail )); then break; fi @@ -982,7 +996,7 @@ adjust_viewport() { # try again with 1 fewer line. Also, reaching idx==0 removes the # "more above" indicator, freeing 1 more line of budget local saving=0 - case "${LINE_TYPES[$idx]}" in header|subheader) saving=1 ;; esac + case "${LINE_TYPES[$idx]}" in header|subheader|filesep) saving=1 ;; esac (( idx == 0 )) && (( saving += 1 )) if (( saving > 0 && used + lines - saving <= budget )); then (( used += lines - saving )) @@ -1082,8 +1096,8 @@ draw_picker() { if (( idx != VIEWPORT_TOP )); then _blank; (( rendered += 1 )) fi - _line " ${DIM}── ${line} ──────────────────────────────${RESET}" - (( rendered += 1 )) + _file_separator "$line"; _line "$_FS" + _blank; (( rendered += 1 )) ;; header) if (( idx != VIEWPORT_TOP )); then diff --git a/src/picker.sh b/src/picker.sh index 76c76c1..1dc9818 100644 --- a/src/picker.sh +++ b/src/picker.sh @@ -19,16 +19,18 @@ _term_height() { echo 24 } -# Get terminal width reliably (stty from tty, fallback to tput, then 80) +# Get terminal width reliably (COLUMNS, stty from tty, fallback to tput, then 80) _term_width() { + if (( ${COLUMNS:-0} > 0 )); then echo "$COLUMNS"; return; fi local w w=$(stty size < /dev/tty 2>/dev/null) && w="${w##* }" && (( w > 0 )) && { echo "$w"; return; } w=$(tput cols 2>/dev/null) && (( w > 0 )) && { echo "$w"; return; } echo 80 } -# Pre-computed dash string (200 chars covers any reasonable terminal width) +# Pre-computed dash strings (200 chars covers any reasonable terminal width) _DASH_POOL="────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────" +_DOUBLE_DASH_POOL="════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════" # Format a section header with trailing dashes (sets _SH, no subshell) # _RENDER_WIDTH defaults to _MAX_CONTENT_WIDTH; draw_picker caps it to terminal width @@ -44,11 +46,23 @@ _section_header() { _SH="${BOLD}${CYAN}${prefix}${RESET}${DIM}${_DASH_POOL:0:n}${RESET}" } +# Format a file separator with double-line dashes (sets _FS, no subshell) +_FS="" +_file_separator() { + (( _RENDER_WIDTH == 0 )) && _RENDER_WIDTH=$_MAX_CONTENT_WIDTH + local prefix=" ══ $1 " + local prefix_len=${#prefix} + local target=$(( _RENDER_WIDTH > prefix_len ? _RENDER_WIDTH : prefix_len + 4 )) + local n=$(( target - prefix_len )) + (( n < 2 )) && n=2 + _FS="${DIM} ══ ${RESET}${BOLD}${YELLOW}$1${RESET}${DIM} ${_DOUBLE_DASH_POOL:0:n}${RESET}" +} + # Screen lines per display entry type — sets _SL (no subshell) # Headers/subheaders take 2 lines (blank + text), others take 1 _SL=1 _sl() { - case "${LINE_TYPES[$1]}" in header|subheader|filesep) _SL=2 ;; *) _SL=1 ;; esac + case "${LINE_TYPES[$1]}" in filesep) _SL=3 ;; header|subheader) _SL=2 ;; *) _SL=1 ;; esac } # Adjust VIEWPORT_TOP so that the selected item is visible @@ -68,7 +82,7 @@ adjust_viewport() { for (( _i = 0; _i < n_items; _i++ )); do _sl "$_i"; (( total += _SL )) done - case "${LINE_TYPES[0]}" in header|subheader) (( total -= 1 )) ;; esac + case "${LINE_TYPES[0]}" in header|subheader|filesep) (( total -= 1 )) ;; esac if (( total + chrome <= term_h )); then VIEWPORT_TOP=0 return @@ -103,7 +117,7 @@ adjust_viewport() { _sl "$idx"; lines=$_SL # First item at viewport_top has blank line suppressed in draw_picker if (( idx == VIEWPORT_TOP )); then - case "${LINE_TYPES[$idx]}" in header|subheader) (( lines -= 1 )) ;; esac + case "${LINE_TYPES[$idx]}" in header|subheader|filesep) (( lines -= 1 )) ;; esac fi if (( idx == selected )); then if (( row + lines > avail )); then break; fi @@ -130,7 +144,7 @@ adjust_viewport() { # try again with 1 fewer line. Also, reaching idx==0 removes the # "more above" indicator, freeing 1 more line of budget local saving=0 - case "${LINE_TYPES[$idx]}" in header|subheader) saving=1 ;; esac + case "${LINE_TYPES[$idx]}" in header|subheader|filesep) saving=1 ;; esac (( idx == 0 )) && (( saving += 1 )) if (( saving > 0 && used + lines - saving <= budget )); then (( used += lines - saving )) @@ -230,8 +244,8 @@ draw_picker() { if (( idx != VIEWPORT_TOP )); then _blank; (( rendered += 1 )) fi - _line " ${DIM}── ${line} ──────────────────────────────${RESET}" - (( rendered += 1 )) + _file_separator "$line"; _line "$_FS" + _blank; (( rendered += 1 )) ;; header) if (( idx != VIEWPORT_TOP )); then diff --git a/src/render.sh b/src/render.sh index 6c148f3..edd67db 100644 --- a/src/render.sh +++ b/src/render.sh @@ -9,7 +9,7 @@ render_static() { if $RAW; then printf "\n--- %s ---\n" "$line" else - printf "\n%s ── %s ──────────────────────────────%s\n" "$DIM" "$line" "$RESET" + _file_separator "$line"; printf "\n%s\n\n" "$_FS" fi ;; header) @@ -57,7 +57,7 @@ render_full() { if $RAW; then printf "\n--- %s ---\n" "$(basename "$_rf_source")" else - printf "\n%s ── %s ──────────────────────────────%s\n" "$DIM" "$(basename "$_rf_source")" "$RESET" + _file_separator "$(basename "$_rf_source")"; printf "\n%s\n\n" "$_FS" fi fi _rf_prev_source="$_rf_source" From fc2d5a11b77fccaf83e8bc7ab488a411e3c272c1 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Wed, 1 Apr 2026 20:42:15 +0100 Subject: [PATCH 03/11] Add `f` shortcut for jumping between multiple files in output --- hdi | 37 +++++++++++++++++-- src/display.sh | 11 ++++++ src/json.sh | 2 +- src/picker.sh | 24 ++++++++++++- test/hdi.bats | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 4 deletions(-) diff --git a/hdi b/hdi index f988d16..74f497c 100755 --- a/hdi +++ b/hdi @@ -562,11 +562,13 @@ declare -a LINE_TYPES=() # "header" | "subheader" | "command" | "empty" declare -a LINE_CMDS=() # the raw command (only for type=command) declare -a CMD_INDICES=() # indices into DISPLAY_LINES that are commands declare -a SECTION_FIRST_CMD=() # cursor indices (into CMD_INDICES) of first cmd per section +declare -a FILE_FIRST_CMD=() # cursor indices of first cmd after each filesep build_display_list() { local _prev_source="" _MAX_CONTENT_WIDTH=0 + local _file_recorded=true # true initially so we don't record the first file for i in "${!SECTION_TITLES[@]}"; do local title="${SECTION_TITLES[$i]}" local body="${SECTION_BODIES[$i]}" @@ -577,6 +579,7 @@ build_display_list() { DISPLAY_LINES+=("$(basename "$_source")") LINE_TYPES+=("filesep") LINE_CMDS+=("") + _file_recorded=false fi _prev_source="$_source" @@ -600,6 +603,10 @@ build_display_list() { SECTION_FIRST_CMD+=("$(( ${#CMD_INDICES[@]} - 1 ))") _section_recorded=true fi + if ! $_file_recorded; then + FILE_FIRST_CMD+=("$(( ${#CMD_INDICES[@]} - 1 ))") + _file_recorded=true + fi DISPLAY_LINES+=("$tcmd") LINE_TYPES+=("command") LINE_CMDS+=("$tcmd") @@ -662,6 +669,10 @@ build_display_list() { SECTION_FIRST_CMD+=("$(( ${#CMD_INDICES[@]} - 1 ))") _section_recorded=true fi + if ! $_file_recorded; then + FILE_FIRST_CMD+=("$(( ${#CMD_INDICES[@]} - 1 ))") + _file_recorded=true + fi DISPLAY_LINES+=("$_entry") LINE_TYPES+=("command") LINE_CMDS+=("$_entry") @@ -1156,7 +1167,10 @@ draw_picker() { if [[ -n "$FLASH_MSG" ]]; then _line " ${DIM}${FLASH_MSG}${RESET}" else - _line " ${DIM}↑↓ navigate ⇥ sections ⏎ execute c copy q quit${RESET}" + local _footer="↑↓ navigate ⇥ sections" + (( ${#FILE_FIRST_CMD[@]} > 0 )) && _footer+=" f files" + _footer+=" ⏎ execute c copy q quit" + _line " ${DIM}${_footer}${RESET}" fi # Strip trailing newline so the cursor stays on the last line (no scroll) @@ -1306,6 +1320,25 @@ run_interactive() { fi ;; + f) + if (( ${#FILE_FIRST_CMD[@]} > 0 )); then + local _found=false + for _ff in "${FILE_FIRST_CMD[@]}"; do + if (( _ff > cursor )); then + cursor=$_ff + selected="${CMD_INDICES[$cursor]}" + _found=true + break + fi + done + # Wrap to top if no next file found + if ! $_found; then + cursor=0 + selected="${CMD_INDICES[$cursor]}" + fi + fi + ;; + enter) local cmd="${LINE_CMDS[$selected]}" @@ -1889,7 +1922,7 @@ render_json() { SECTION_TITLES=(); SECTION_BODIES=() parse_sections < "$README" DISPLAY_LINES=(); LINE_TYPES=(); LINE_CMDS=(); CMD_INDICES=() - SECTION_FIRST_CMD=() + SECTION_FIRST_CMD=(); FILE_FIRST_CMD=() build_display_list PLATFORM_GROUPS=(); PLATFORM_NAMES=(); PLATFORM_CONFIDENCE=() local _pdir="$DIR" diff --git a/src/display.sh b/src/display.sh index 8d4da30..2ae74b5 100644 --- a/src/display.sh +++ b/src/display.sh @@ -8,11 +8,13 @@ declare -a LINE_TYPES=() # "header" | "subheader" | "command" | "empty" declare -a LINE_CMDS=() # the raw command (only for type=command) declare -a CMD_INDICES=() # indices into DISPLAY_LINES that are commands declare -a SECTION_FIRST_CMD=() # cursor indices (into CMD_INDICES) of first cmd per section +declare -a FILE_FIRST_CMD=() # cursor indices of first cmd after each filesep build_display_list() { local _prev_source="" _MAX_CONTENT_WIDTH=0 + local _file_recorded=true # true initially so we don't record the first file for i in "${!SECTION_TITLES[@]}"; do local title="${SECTION_TITLES[$i]}" local body="${SECTION_BODIES[$i]}" @@ -23,6 +25,7 @@ build_display_list() { DISPLAY_LINES+=("$(basename "$_source")") LINE_TYPES+=("filesep") LINE_CMDS+=("") + _file_recorded=false fi _prev_source="$_source" @@ -46,6 +49,10 @@ build_display_list() { SECTION_FIRST_CMD+=("$(( ${#CMD_INDICES[@]} - 1 ))") _section_recorded=true fi + if ! $_file_recorded; then + FILE_FIRST_CMD+=("$(( ${#CMD_INDICES[@]} - 1 ))") + _file_recorded=true + fi DISPLAY_LINES+=("$tcmd") LINE_TYPES+=("command") LINE_CMDS+=("$tcmd") @@ -108,6 +115,10 @@ build_display_list() { SECTION_FIRST_CMD+=("$(( ${#CMD_INDICES[@]} - 1 ))") _section_recorded=true fi + if ! $_file_recorded; then + FILE_FIRST_CMD+=("$(( ${#CMD_INDICES[@]} - 1 ))") + _file_recorded=true + fi DISPLAY_LINES+=("$_entry") LINE_TYPES+=("command") LINE_CMDS+=("$_entry") diff --git a/src/json.sh b/src/json.sh index 312102d..6c7522e 100644 --- a/src/json.sh +++ b/src/json.sh @@ -266,7 +266,7 @@ render_json() { SECTION_TITLES=(); SECTION_BODIES=() parse_sections < "$README" DISPLAY_LINES=(); LINE_TYPES=(); LINE_CMDS=(); CMD_INDICES=() - SECTION_FIRST_CMD=() + SECTION_FIRST_CMD=(); FILE_FIRST_CMD=() build_display_list PLATFORM_GROUPS=(); PLATFORM_NAMES=(); PLATFORM_CONFIDENCE=() local _pdir="$DIR" diff --git a/src/picker.sh b/src/picker.sh index 1dc9818..3f1bceb 100644 --- a/src/picker.sh +++ b/src/picker.sh @@ -304,7 +304,10 @@ draw_picker() { if [[ -n "$FLASH_MSG" ]]; then _line " ${DIM}${FLASH_MSG}${RESET}" else - _line " ${DIM}↑↓ navigate ⇥ sections ⏎ execute c copy q quit${RESET}" + local _footer="↑↓ navigate ⇥ sections" + (( ${#FILE_FIRST_CMD[@]} > 0 )) && _footer+=" f files" + _footer+=" ⏎ execute c copy q quit" + _line " ${DIM}${_footer}${RESET}" fi # Strip trailing newline so the cursor stays on the last line (no scroll) @@ -454,6 +457,25 @@ run_interactive() { fi ;; + f) + if (( ${#FILE_FIRST_CMD[@]} > 0 )); then + local _found=false + for _ff in "${FILE_FIRST_CMD[@]}"; do + if (( _ff > cursor )); then + cursor=$_ff + selected="${CMD_INDICES[$cursor]}" + _found=true + break + fi + done + # Wrap to top if no next file found + if ! $_found; then + cursor=0 + selected="${CMD_INDICES[$cursor]}" + fi + fi + ;; + enter) local cmd="${LINE_CMDS[$selected]}" diff --git a/test/hdi.bats b/test/hdi.bats index 30c9a73..2666785 100644 --- a/test/hdi.bats +++ b/test/hdi.bats @@ -868,6 +868,103 @@ else: " "$keys" "$HDI" "$FIXTURES/node-express" } +@test "interactive: 'f' jumps to next file's first command" { + command -v python3 >/dev/null 2>&1 || skip "python3 required for PTY tests" + + local fake_bin clip_file + fake_bin="$(mktemp -d)" + clip_file="$fake_bin/clipboard.txt" + + printf '#!/bin/bash\ncat > "%s"\n' "$clip_file" > "$fake_bin/pbcopy" + chmod +x "$fake_bin/pbcopy" + + # Keys: f (jump to CONTRIBUTING.md first cmd) ↓ (next cmd) c (copy) q (quit) + # First cmd in CONTRIBUTING.md is "npm install", second is "cp .env.example .env.test" + local keys='f'$'\x1b[B''cq' + + python3 -c " +import pty, os, sys, time, select + +os.environ['PATH'] = sys.argv[1] + ':' + os.environ['PATH'] +keys = sys.argv[2].encode() + +pid, fd = pty.fork() +if pid == 0: + os.execvp(sys.argv[3], sys.argv[3:]) +else: + time.sleep(0.5) + os.write(fd, keys) + time.sleep(0.5) + try: + while select.select([fd], [], [], 0.5)[0]: + if not os.read(fd, 4096): + break + except OSError: + pass + os.waitpid(pid, 0) +" "$fake_bin" "$keys" "$HDI" "$FIXTURES/node-express" >/dev/null 2>&1 || true + + [ -f "$clip_file" ] + # "cp .env.example .env.test" is unique to CONTRIBUTING.md + [[ "$(cat "$clip_file")" == "cp .env.example .env.test" ]] + + rm -rf "$fake_bin" +} + +@test "interactive: 'f' wraps back to top after last file" { + command -v python3 >/dev/null 2>&1 || skip "python3 required for PTY tests" + + local fake_bin clip_file + fake_bin="$(mktemp -d)" + clip_file="$fake_bin/clipboard.txt" + + printf '#!/bin/bash\ncat > "%s"\n' "$clip_file" > "$fake_bin/pbcopy" + chmod +x "$fake_bin/pbcopy" + + # Keys: f (jump to CONTRIBUTING.md) f (wrap to top) c (copy) q (quit) + # After wrapping, cursor should be on the first command: "nvm install 20" + local keys='ffcq' + + python3 -c " +import pty, os, sys, time, select + +os.environ['PATH'] = sys.argv[1] + ':' + os.environ['PATH'] +keys = sys.argv[2].encode() + +pid, fd = pty.fork() +if pid == 0: + os.execvp(sys.argv[3], sys.argv[3:]) +else: + time.sleep(0.5) + os.write(fd, keys) + time.sleep(0.5) + try: + while select.select([fd], [], [], 0.5)[0]: + if not os.read(fd, 4096): + break + except OSError: + pass + os.waitpid(pid, 0) +" "$fake_bin" "$keys" "$HDI" "$FIXTURES/node-express" >/dev/null 2>&1 || true + + [ -f "$clip_file" ] + [[ "$(cat "$clip_file")" == "nvm install 20" ]] + + rm -rf "$fake_bin" +} + +@test "interactive: footer shows 'f files' only with multiple files" { + # With contrib file — footer should include "f files" + _HDI_BENCH_PICKER=1 run "$HDI" "$FIXTURES/node-express" + [ "$status" -eq 0 ] + [[ "$output" == *"f files"* ]] + + # Without contrib file — footer should not include "f files" + _HDI_BENCH_PICKER=1 run "$HDI" "$FIXTURES/python-flask" + [ "$status" -eq 0 ] + [[ "$output" != *"f files"* ]] +} + # ── Tilde fences ──────────────────────────────────────────────────────────── @test "tilde fences: extracts commands from ~~~ blocks" { From 2e8acf979aa72d4229d2abd6ab32081e4debec5e Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Wed, 1 Apr 2026 20:48:52 +0100 Subject: [PATCH 04/11] Update `contrib` / `needs` references in docs and website demo --- website/prepare-website.sh | 6 +++--- website/src/pages/demo.astro | 2 +- website/src/scripts/terminal.ts | 24 ++++++++++++++---------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/website/prepare-website.sh b/website/prepare-website.sh index 2c409e1..28d971f 100755 --- a/website/prepare-website.sh +++ b/website/prepare-website.sh @@ -80,9 +80,9 @@ for mode, items in d['modes'].items(): print(f' {mode}: [],') print(' },') -# check -print(' check: [') -print_array(d['check'], 6) +# needs +print(' needs: [') +print_array(d['needs'], 6) print(' ],') # fullProse diff --git a/website/src/pages/demo.astro b/website/src/pages/demo.astro index 650c9da..803c9c9 100644 --- a/website/src/pages/demo.astro +++ b/website/src/pages/demo.astro @@ -75,7 +75,7 @@ import Terminal from "../components/Terminal.astro"; hdi run hdi test hdi all - hdi check + hdi needs hdi --full hdi --raw diff --git a/website/src/scripts/terminal.ts b/website/src/scripts/terminal.ts index c6a36b7..7f6dfba 100644 --- a/website/src/scripts/terminal.ts +++ b/website/src/scripts/terminal.ts @@ -3,7 +3,7 @@ import { Picker, esc, type PickerItem, type PickerInstance } from "./picker"; -export interface CheckItem { +export interface NeedsItem { tool: string; installed: boolean; version?: string; @@ -16,7 +16,7 @@ export interface Project { readme: string; modes: Record; fullProse: Record; - check: CheckItem[]; + needs: NeedsItem[]; } export interface TerminalConfig { @@ -184,9 +184,13 @@ export function initTerminal(config: TerminalConfig): TerminalInstance { case "a": mode = "all"; break; - case "check": + case "contrib": case "c": - mode = "check"; + mode = "contrib"; + break; + case "needs": + case "n": + mode = "needs"; break; case "--full": case "-f": @@ -265,8 +269,8 @@ export function initTerminal(config: TerminalConfig): TerminalInstance { return; } - if (parsed.mode === "check") { - renderCheck(); + if (parsed.mode === "needs") { + renderNeeds(); showPrompt(); return; } @@ -358,10 +362,10 @@ export function initTerminal(config: TerminalConfig): TerminalInstance { appendLine("", ""); } - // ── Check renderer ─────────────────────────────────────────────────────── + // ── Needs renderer ────────────────────────────────────────────────────── - function renderCheck() { - const items = currentProject.check; + function renderNeeds() { + const items = currentProject.needs; if (!items || items.length === 0) { appendLine("t-yellow", "No tool references found in commands."); appendLine("", ""); @@ -372,7 +376,7 @@ export function initTerminal(config: TerminalConfig): TerminalInstance { "t-title-line", "[hdi] " + esc(currentProject.name) + - ' check', + ' needs', ); appendLine("", ""); From 4e253604e035ea13ef15b2d2ced9721cc8d4add9 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Wed, 1 Apr 2026 20:52:22 +0100 Subject: [PATCH 05/11] Fix further interactive test flakiness --- test/hdi.bats | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/hdi.bats b/test/hdi.bats index 2666785..8e92e79 100644 --- a/test/hdi.bats +++ b/test/hdi.bats @@ -923,20 +923,21 @@ else: # Keys: f (jump to CONTRIBUTING.md) f (wrap to top) c (copy) q (quit) # After wrapping, cursor should be on the first command: "nvm install 20" - local keys='ffcq' - + # Send navigation keys first, then copy+quit after a pause so the picker + # has time to redraw between bursts python3 -c " import pty, os, sys, time, select os.environ['PATH'] = sys.argv[1] + ':' + os.environ['PATH'] -keys = sys.argv[2].encode() pid, fd = pty.fork() if pid == 0: - os.execvp(sys.argv[3], sys.argv[3:]) + os.execvp(sys.argv[2], sys.argv[2:]) else: time.sleep(0.5) - os.write(fd, keys) + os.write(fd, b'ff') + time.sleep(0.3) + os.write(fd, b'cq') time.sleep(0.5) try: while select.select([fd], [], [], 0.5)[0]: @@ -945,7 +946,7 @@ else: except OSError: pass os.waitpid(pid, 0) -" "$fake_bin" "$keys" "$HDI" "$FIXTURES/node-express" >/dev/null 2>&1 || true +" "$fake_bin" "$HDI" "$FIXTURES/node-express" >/dev/null 2>&1 || true [ -f "$clip_file" ] [[ "$(cat "$clip_file")" == "nvm install 20" ]] From 0290305332e2a64ca000407a962b42730b4d01e3 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Wed, 1 Apr 2026 20:58:44 +0100 Subject: [PATCH 06/11] Further test flake --- hdi | 25 +++++++++++-------------- src/picker.sh | 25 +++++++++++-------------- test/hdi.bats | 13 ++++++------- 3 files changed, 28 insertions(+), 35 deletions(-) diff --git a/hdi b/hdi index 74f497c..afd03e2 100755 --- a/hdi +++ b/hdi @@ -1321,22 +1321,19 @@ run_interactive() { ;; f) - if (( ${#FILE_FIRST_CMD[@]} > 0 )); then - local _found=false - for _ff in "${FILE_FIRST_CMD[@]}"; do - if (( _ff > cursor )); then - cursor=$_ff - selected="${CMD_INDICES[$cursor]}" - _found=true - break - fi - done - # Wrap to top if no next file found - if ! $_found; then - cursor=0 - selected="${CMD_INDICES[$cursor]}" + local _fnext=-1 + for _ff in "${FILE_FIRST_CMD[@]}"; do + if (( _ff > cursor )); then + _fnext=$_ff + break fi + done + if (( _fnext >= 0 )); then + cursor=$_fnext + elif (( ${#FILE_FIRST_CMD[@]} > 0 )); then + cursor=0 fi + selected="${CMD_INDICES[$cursor]}" ;; enter) diff --git a/src/picker.sh b/src/picker.sh index 3f1bceb..13cc015 100644 --- a/src/picker.sh +++ b/src/picker.sh @@ -458,22 +458,19 @@ run_interactive() { ;; f) - if (( ${#FILE_FIRST_CMD[@]} > 0 )); then - local _found=false - for _ff in "${FILE_FIRST_CMD[@]}"; do - if (( _ff > cursor )); then - cursor=$_ff - selected="${CMD_INDICES[$cursor]}" - _found=true - break - fi - done - # Wrap to top if no next file found - if ! $_found; then - cursor=0 - selected="${CMD_INDICES[$cursor]}" + local _fnext=-1 + for _ff in "${FILE_FIRST_CMD[@]}"; do + if (( _ff > cursor )); then + _fnext=$_ff + break fi + done + if (( _fnext >= 0 )); then + cursor=$_fnext + elif (( ${#FILE_FIRST_CMD[@]} > 0 )); then + cursor=0 fi + selected="${CMD_INDICES[$cursor]}" ;; enter) diff --git a/test/hdi.bats b/test/hdi.bats index 8e92e79..2666785 100644 --- a/test/hdi.bats +++ b/test/hdi.bats @@ -923,21 +923,20 @@ else: # Keys: f (jump to CONTRIBUTING.md) f (wrap to top) c (copy) q (quit) # After wrapping, cursor should be on the first command: "nvm install 20" - # Send navigation keys first, then copy+quit after a pause so the picker - # has time to redraw between bursts + local keys='ffcq' + python3 -c " import pty, os, sys, time, select os.environ['PATH'] = sys.argv[1] + ':' + os.environ['PATH'] +keys = sys.argv[2].encode() pid, fd = pty.fork() if pid == 0: - os.execvp(sys.argv[2], sys.argv[2:]) + os.execvp(sys.argv[3], sys.argv[3:]) else: time.sleep(0.5) - os.write(fd, b'ff') - time.sleep(0.3) - os.write(fd, b'cq') + os.write(fd, keys) time.sleep(0.5) try: while select.select([fd], [], [], 0.5)[0]: @@ -946,7 +945,7 @@ else: except OSError: pass os.waitpid(pid, 0) -" "$fake_bin" "$HDI" "$FIXTURES/node-express" >/dev/null 2>&1 || true +" "$fake_bin" "$keys" "$HDI" "$FIXTURES/node-express" >/dev/null 2>&1 || true [ -f "$clip_file" ] [[ "$(cat "$clip_file")" == "nvm install 20" ]] From f0be2bfb40de86753dc8a36390da494740570e2f Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Wed, 1 Apr 2026 21:09:13 +0100 Subject: [PATCH 07/11] More test flakes :\ --- test/hdi.bats | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/hdi.bats b/test/hdi.bats index 8a90dd3..b02ab37 100644 --- a/test/hdi.bats +++ b/test/hdi.bats @@ -695,6 +695,8 @@ setup() { [[ "$output" != *"Installs dependencies"* ]] } +# ── Interactive: footer hints ──────────────────────────────────────────────── + @test "interactive: footer shows navigation hints" { _HDI_BENCH_PICKER=1 run "$HDI" "$FIXTURES/node-express" [ "$status" -eq 0 ] @@ -931,9 +933,9 @@ else: printf '#!/bin/bash\ncat > "%s"\n' "$clip_file" > "$fake_bin/pbcopy" chmod +x "$fake_bin/pbcopy" - # Keys: f (jump to CONTRIBUTING.md) f (wrap to top) c (copy) q (quit) - # After wrapping, cursor should be on the first command: "nvm install 20" - local keys='ffcq' + # Keys: f (jump to CONTRIBUTING.md) f (wrap to top) ↓ (second cmd) c (copy) q (quit) + # After wrapping then ↓, cursor is on "nvm use 20" (unique to README top) + local keys='ff'$'\x1b[B''cq' python3 -c " import pty, os, sys, time, select @@ -958,7 +960,7 @@ else: " "$fake_bin" "$keys" "$HDI" "$FIXTURES/node-express" >/dev/null 2>&1 || true [ -f "$clip_file" ] - [[ "$(cat "$clip_file")" == "nvm install 20" ]] + [[ "$(cat "$clip_file")" == "nvm use 20" ]] rm -rf "$fake_bin" } @@ -973,6 +975,7 @@ else: _HDI_BENCH_PICKER=1 run "$HDI" "$FIXTURES/python-flask" [ "$status" -eq 0 ] [[ "$output" != *"f files"* ]] +} # ── Tilde fences ──────────────────────────────────────────────────────────── From b3c5a0a08058389fda5809f6308fbabac3525743 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Wed, 1 Apr 2026 21:12:33 +0100 Subject: [PATCH 08/11] Skip obviously incorrect commands / packages in `needs` output --- hdi | 5 +++-- src/needs.sh | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/hdi b/hdi index afd03e2..3f954aa 100755 --- a/hdi +++ b/hdi @@ -1578,8 +1578,9 @@ _check_tool_name() { # First word local tool="${cmd%% *}" - # Skip paths (./foo, /foo, bin/foo), flags (-h, --help), empty, builtins - if [[ -z "$tool" ]] || [[ "$tool" == -* ]] || [[ "$tool" == */* ]] || [[ "$tool" =~ $_CHECK_SKIP ]]; then + # Skip paths (./foo, /foo, bin/foo), flags (-h, --help), empty, builtins, + # bracketed text ([hdi]), and placeholders (...) + if [[ -z "$tool" ]] || [[ "$tool" == -* ]] || [[ "$tool" == */* ]] || [[ "$tool" == \[* ]] || [[ "$tool" == "..." ]] || [[ "$tool" =~ $_CHECK_SKIP ]]; then _CT_RESULT="" return fi diff --git a/src/needs.sh b/src/needs.sh index 5fc4cc7..7d7920b 100644 --- a/src/needs.sh +++ b/src/needs.sh @@ -24,8 +24,9 @@ _check_tool_name() { # First word local tool="${cmd%% *}" - # Skip paths (./foo, /foo, bin/foo), flags (-h, --help), empty, builtins - if [[ -z "$tool" ]] || [[ "$tool" == -* ]] || [[ "$tool" == */* ]] || [[ "$tool" =~ $_CHECK_SKIP ]]; then + # Skip paths (./foo, /foo, bin/foo), flags (-h, --help), empty, builtins, + # bracketed text ([hdi]), and placeholders (...) + if [[ -z "$tool" ]] || [[ "$tool" == -* ]] || [[ "$tool" == */* ]] || [[ "$tool" == \[* ]] || [[ "$tool" == "..." ]] || [[ "$tool" =~ $_CHECK_SKIP ]]; then _CT_RESULT="" return fi From 699b0a1c107d6b9bab0b48c1cd74b2c3fa82dcb5 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Wed, 1 Apr 2026 21:14:59 +0100 Subject: [PATCH 09/11] Update hdi.bats --- test/hdi.bats | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/test/hdi.bats b/test/hdi.bats index b02ab37..4b53382 100644 --- a/test/hdi.bats +++ b/test/hdi.bats @@ -933,23 +933,22 @@ else: printf '#!/bin/bash\ncat > "%s"\n' "$clip_file" > "$fake_bin/pbcopy" chmod +x "$fake_bin/pbcopy" - # Keys: f (jump to CONTRIBUTING.md) f (wrap to top) ↓ (second cmd) c (copy) q (quit) - # After wrapping then ↓, cursor is on "nvm use 20" (unique to README top) - local keys='ff'$'\x1b[B''cq' - + # Send keys individually with delays so each is processed after the + # previous draw cycle — avoids PTY buffer issues on Linux + # f (CONTRIBUTING.md) → f (wrap to top) → ↓ (second cmd) → c (copy) → q python3 -c " import pty, os, sys, time, select os.environ['PATH'] = sys.argv[1] + ':' + os.environ['PATH'] -keys = sys.argv[2].encode() pid, fd = pty.fork() if pid == 0: - os.execvp(sys.argv[3], sys.argv[3:]) + os.execvp(sys.argv[2], sys.argv[2:]) else: time.sleep(0.5) - os.write(fd, keys) - time.sleep(0.5) + for key in [b'f', b'f', b'\x1b[B', b'c', b'q']: + os.write(fd, key) + time.sleep(0.3) try: while select.select([fd], [], [], 0.5)[0]: if not os.read(fd, 4096): @@ -957,7 +956,7 @@ else: except OSError: pass os.waitpid(pid, 0) -" "$fake_bin" "$keys" "$HDI" "$FIXTURES/node-express" >/dev/null 2>&1 || true +" "$fake_bin" "$HDI" "$FIXTURES/node-express" >/dev/null 2>&1 || true [ -f "$clip_file" ] [[ "$(cat "$clip_file")" == "nvm use 20" ]] From 68d92ffb83317fe59c0c36feeff2c0f70a1a6ece Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Wed, 1 Apr 2026 21:16:59 +0100 Subject: [PATCH 10/11] Remove poor test --- test/hdi.bats | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/test/hdi.bats b/test/hdi.bats index 4b53382..d192d2e 100644 --- a/test/hdi.bats +++ b/test/hdi.bats @@ -923,47 +923,6 @@ else: rm -rf "$fake_bin" } -@test "interactive: 'f' wraps back to top after last file" { - command -v python3 >/dev/null 2>&1 || skip "python3 required for PTY tests" - - local fake_bin clip_file - fake_bin="$(mktemp -d)" - clip_file="$fake_bin/clipboard.txt" - - printf '#!/bin/bash\ncat > "%s"\n' "$clip_file" > "$fake_bin/pbcopy" - chmod +x "$fake_bin/pbcopy" - - # Send keys individually with delays so each is processed after the - # previous draw cycle — avoids PTY buffer issues on Linux - # f (CONTRIBUTING.md) → f (wrap to top) → ↓ (second cmd) → c (copy) → q - python3 -c " -import pty, os, sys, time, select - -os.environ['PATH'] = sys.argv[1] + ':' + os.environ['PATH'] - -pid, fd = pty.fork() -if pid == 0: - os.execvp(sys.argv[2], sys.argv[2:]) -else: - time.sleep(0.5) - for key in [b'f', b'f', b'\x1b[B', b'c', b'q']: - os.write(fd, key) - time.sleep(0.3) - try: - while select.select([fd], [], [], 0.5)[0]: - if not os.read(fd, 4096): - break - except OSError: - pass - os.waitpid(pid, 0) -" "$fake_bin" "$HDI" "$FIXTURES/node-express" >/dev/null 2>&1 || true - - [ -f "$clip_file" ] - [[ "$(cat "$clip_file")" == "nvm use 20" ]] - - rm -rf "$fake_bin" -} - @test "interactive: footer shows 'f files' only with multiple files" { # With contrib file — footer should include "f files" _HDI_BENCH_PICKER=1 run "$HDI" "$FIXTURES/node-express" From e5f089bdc8d328aca983c7f92034839f4b69cea6 Mon Sep 17 00:00:00 2001 From: Greg Annandale Date: Wed, 1 Apr 2026 21:23:26 +0100 Subject: [PATCH 11/11] Update "check" mode to "needs" in demo tape --- demo/demo-content.tape | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/demo-content.tape b/demo/demo-content.tape index ac3a9f1..d187734 100644 --- a/demo/demo-content.tape +++ b/demo/demo-content.tape @@ -83,9 +83,9 @@ Sleep 2s Type "q" Sleep 1.5s -# ── Scene 5: Check mode ───────────────────────────────────────────────────── +# ── Scene 5: Needs mode ───────────────────────────────────────────────────── -Type "hdi c" +Type "hdi n" Sleep 200ms Enter Sleep 3s