From d53260360672a61e55262be7b28dac1a9255f4cf Mon Sep 17 00:00:00 2001 From: McAmner Date: Fri, 17 Jul 2026 15:03:22 +0200 Subject: [PATCH] refactor(mqlaunch): extract prompt concern --- .../2026-07-17-prompt-concern-extraction.md | 100 +++++++++ mqlaunch/lib/prompts.sh | 206 +++++++++++++++++ terminal/launchers/mqlaunch.sh | 212 +----------------- tests/monolith-delayer-smoke.sh | 1 + tests/prompt-lib-smoke.sh | 51 +++++ tools/scripts/test-all.sh | 1 + 6 files changed, 367 insertions(+), 204 deletions(-) create mode 100644 docs/plans/2026-07-17-prompt-concern-extraction.md create mode 100644 mqlaunch/lib/prompts.sh create mode 100755 tests/prompt-lib-smoke.sh diff --git a/docs/plans/2026-07-17-prompt-concern-extraction.md b/docs/plans/2026-07-17-prompt-concern-extraction.md new file mode 100644 index 0000000..f000f56 --- /dev/null +++ b/docs/plans/2026-07-17-prompt-concern-extraction.md @@ -0,0 +1,100 @@ +# Prompt Concern Extraction Implementation Plan + +## Goal + +Move prompt and AI-backend actions out of the live `mqlaunch` monolith without +changing their command or menu contracts. + +## Owner repo + +macos-scripts + +## Secondary repos + +None. + +## Architecture boundary + +* `macos-scripts` owns the `mqlaunch` runtime, prompt actions, menus, and tests. +* `mqobsidian` remains an optional prompt source and durable memory; it is not + changed by this work. + +## Non-goals + +* Changing prompt storage, menu labels, routes, or backup format. +* Changing the AI backend. +* Migrating performance routes or other Step 11a concerns. + +## Approval gates + +* Before file writes: approved by the user's "kör det steget" instruction. +* Before commit: yes. +* Before push/merge: yes. +* Before deletion/settings changes: yes. + +## Test gates + +* `zsh -n terminal/launchers/mqlaunch.sh mqlaunch/lib/prompts.sh` +* `MACOS_SCRIPTS_HOME="$PWD" tests/prompt-lib-smoke.sh` +* `MACOS_SCRIPTS_HOME="$PWD" tests/monolith-delayer-smoke.sh` +* `MACOS_SCRIPTS_HOME="$PWD" tools/scripts/test-all.sh` + +## Rollback + +Restore the seven functions in `terminal/launchers/mqlaunch.sh`, remove the +library source block and concern-test registrations, then delete +`mqlaunch/lib/prompts.sh` and `tests/prompt-lib-smoke.sh`. + +### Task 1: Lock the prompt contracts + +**Purpose:** Prevent behavior drift while moving the concern. + +**Files:** + +* Create: `tests/prompt-lib-smoke.sh` +* Modify: `tools/scripts/test-all.sh` + +**Steps:** + +1. Test prompt-directory precedence and missing-directory failure. +2. Test all AI backend status states. +3. Test that AI modes are forwarded unchanged. +4. Register the test in the full selftest. + +**Expected result:** The focused test fails until the shared library exists. + +### Task 2: Extract the prompt concern + +**Purpose:** Continue Step 11a with one concern and one live definition. + +**Files:** + +* Create: `mqlaunch/lib/prompts.sh` +* Modify: `terminal/launchers/mqlaunch.sh` +* Modify: `tests/monolith-delayer-smoke.sh` + +**Steps:** + +1. Move the seven prompt and AI functions to the zsh library. +2. Source the library from the launcher with the existing debug fallback. +3. Register all seven functions in the monolith de-layering gate. +4. Run focused syntax and behavior tests. + +**Expected result:** Routes and menus keep working while the launcher no longer +defines the prompt concern. + +### Task 3: Verify the complete runtime + +**Purpose:** Catch cross-concern regressions. + +**Files:** + +* Read-only reference: `tools/scripts/test-all.sh` + +**Steps:** + +1. Run the full selftest with `MACOS_SCRIPTS_HOME` set to this worktree. +2. Inspect status and diff. +3. Stop before commit and report results. + +**Expected result:** All tests pass and only the scoped files differ. diff --git a/mqlaunch/lib/prompts.sh b/mqlaunch/lib/prompts.sh new file mode 100644 index 0000000..d434529 --- /dev/null +++ b/mqlaunch/lib/prompts.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env zsh + +# Resolves prompt dir. +resolve_prompt_dir() { + local candidate + for candidate in "$BASE_DIR/ai-prompts" "$PROMPT_DIR"; do + if [[ -d "$candidate" ]]; then + print -r -- "$candidate" + return 0 + fi + done + return 1 +} + +# Resolves ai status. +resolve_ai_status() { + if [[ -x "$AI_SCRIPT" ]]; then + print -r -- "OK" + elif [[ -e "$AI_SCRIPT" ]]; then + print -r -- "FOUND_NOT_EXECUTABLE" + else + print -r -- "MISSING" + fi +} + +# Runs ai through guardrails before acting. +safe_run_ai() { + local mode="$1" + + if [[ -x "$AI_SCRIPT" ]]; then + "$AI_SCRIPT" "$mode" + else + print_header + row "AI BACKEND STATUS" + empty_row + if [[ -e "$AI_SCRIPT" ]]; then + row "ai-mode.sh found but not executable." + row "Run:" + row " chmod +x $AI_SCRIPT" + else + row "ai-mode.sh missing." + row "Expected:" + row " $AI_SCRIPT" + fi + print_footer + pause_enter + fi +} + +# fzf: bläddra och kopiera sparade AI-prompts från mqobsidian/_prompts/ +prompts_pick() { + local vault_dir="${MQ_OBSIDIAN_DIR:-$HOME/mqobsidian}" + local prompts_dir="$vault_dir/_prompts/saved-prompts-md-export" + local fzf_bin + fzf_bin="$(command -v fzf 2>/dev/null || true)" + + if [[ ! -d "$prompts_dir" ]]; then + printf "Prompts directory not found: %s\n" "$prompts_dir" >&2 + return 1 + fi + + if [[ -z "$fzf_bin" ]]; then + printf "fzf is not installed. Install: brew install fzf\n" >&2 + return 1 + fi + + local selected + selected="$( + find "$prompts_dir" -name "*.md" -not -name "INDEX.md" -not -name "README.md" -not -name "EXPORT_NOTES.md" -not -name "PROMPT_EXPORT_INDEX.md" | sort | while IFS= read -r f; do + label="$(basename "$(dirname "$f")" | sed 's/^[0-9]*_//')/$(basename "$f" .md | sed 's/_/ /g')" + printf "%s\t%s\n" "$label" "$f" + done \ + | "$fzf_bin" \ + --delimiter='\t' \ + --with-nth=1 \ + --preview='head -40 {2}' \ + --preview-window='right:55%:wrap' \ + --reverse \ + --border \ + --header='Select prompt → copy to clipboard (ESC = cancel)' \ + --prompt='prompt > ' \ + --height=80% \ + | cut -f2 + )" + + [[ -z "$selected" ]] && return 0 + + if command -v pbcopy >/dev/null 2>&1; then + pbcopy < "$selected" + printf "Copied to clipboard: %s\n" "$(basename "$selected" .txt | sed 's/_/ /g')" + else + printf "pbcopy not available — printing prompt:\n\n" + cat "$selected" + fi +} + +# Opens ai prompts folder. +open_ai_prompts_folder() { + local target="" + target="$(resolve_prompt_dir 2>/dev/null || true)" + + print_header + row "OPEN AI PROMPTS FOLDER" + empty_row + + if [[ -n "$target" ]]; then + row "Opening:" + row " $target" + print_footer + open "$target" + else + row "Prompt dir missing." + row "Checked:" + row " $HOME/macos-scripts/ai-prompts" + row " $PROMPT_DIR" + print_footer + pause_enter + fi +} + +# Shows prompt files. +show_prompt_files() { + local resolved_prompt_dir="" + local -a files + local f + local shown=0 + + resolved_prompt_dir="$(resolve_prompt_dir 2>/dev/null || true)" + + print_header + row "PROMPT FILES" + empty_row + + if [[ -z "$resolved_prompt_dir" ]]; then + row "Prompt dir missing." + row "Checked:" + row " $HOME/macos-scripts/ai-prompts" + row " $PROMPT_DIR" + else + files=("$resolved_prompt_dir"/*(.N)) + if (( ${#files[@]} == 0 )); then + row "No prompt files found." + row "Folder:" + row " $resolved_prompt_dir" + else + for f in "${files[@]}"; do + row " - ${f:t}" + ((shown++)) + if (( shown >= 20 && ${#files[@]} > 20 )); then + row " ..." + break + fi + done + empty_row + row "Total files: ${#files[@]}" + row "Folder: $resolved_prompt_dir" + fi + fi + + print_footer + pause_enter +} + +# Backs up prompts. +backup_prompts() { + local resolved_prompt_dir="" + local stamp backup_file + + resolved_prompt_dir="$(resolve_prompt_dir 2>/dev/null || true)" + + if [[ -z "$resolved_prompt_dir" ]]; then + echo "${C_ERR}Prompt dir missing.${C_RESET}" + pause_enter + return + fi + + if ! command -v zip >/dev/null 2>&1; then + echo "${C_ERR}zip is missing on this system.${C_RESET}" + pause_enter + return + fi + + mkdir -p "$BACKUP_DIR" + stamp="$(date '+%Y%m%d-%H%M%S')" + backup_file="$BACKUP_DIR/ai-prompts-$stamp.zip" + + ( + cd "$(dirname "$resolved_prompt_dir")" || exit 1 + zip -rq "$backup_file" "$(basename "$resolved_prompt_dir")" + ) + + print_header + row "PROMPT BACKUP" + empty_row + + if [[ -f "$backup_file" ]]; then + row "Backup created successfully." + row "File:" + row " $backup_file" + else + row "Backup failed." + fi + + print_footer + pause_enter +} diff --git a/terminal/launchers/mqlaunch.sh b/terminal/launchers/mqlaunch.sh index 0c268ee..2ec1830 100755 --- a/terminal/launchers/mqlaunch.sh +++ b/terminal/launchers/mqlaunch.sh @@ -275,52 +275,14 @@ open_folder_screen() { fi } -# Resolves prompt dir. -resolve_prompt_dir() { - local candidate - for candidate in "$BASE_DIR/ai-prompts" "$PROMPT_DIR"; do - if [[ -d "$candidate" ]]; then - print -r -- "$candidate" - return 0 - fi - done - return 1 -} - -# Resolves ai status. -resolve_ai_status() { - if [[ -x "$AI_SCRIPT" ]]; then - print -r -- "OK" - elif [[ -e "$AI_SCRIPT" ]]; then - print -r -- "FOUND_NOT_EXECUTABLE" - else - print -r -- "MISSING" - fi -} - -# Runs ai through guardrails before acting. -safe_run_ai() { - local mode="$1" - - if [[ -x "$AI_SCRIPT" ]]; then - "$AI_SCRIPT" "$mode" - else - print_header - row "AI BACKEND STATUS" - empty_row - if [[ -e "$AI_SCRIPT" ]]; then - row "ai-mode.sh found but not executable." - row "Run:" - row " chmod +x $AI_SCRIPT" - else - row "ai-mode.sh missing." - row "Expected:" - row " $AI_SCRIPT" - fi - print_footer - pause_enter - fi -} +# Prompt and AI-backend actions live in one shared concern library (Step 11a +# monolith de-layering). The functions retain the launcher's ambient UI helpers +# and configuration variables; routes and menus continue to call them by name. +if [[ -f "$BASE_DIR/mqlaunch/lib/prompts.sh" ]]; then + source "$BASE_DIR/mqlaunch/lib/prompts.sh" +else + mq_debug "mqlaunch: prompts lib missing: mqlaunch/lib/prompts.sh" +fi # Network concern — status, diagnostics, and connectivity actions — lives in a # dedicated library (Step 11a monolith de-layering, audit P4). Sourced into this @@ -388,53 +350,6 @@ else mq_debug "mqlaunch: repo picker lib missing: mqlaunch/lib/repo-picker.sh" fi -# fzf: bläddra och kopiera sparade AI-prompts från mqobsidian/_prompts/ -prompts_pick() { - local vault_dir="${MQ_OBSIDIAN_DIR:-$HOME/mqobsidian}" - local prompts_dir="$vault_dir/_prompts/saved-prompts-md-export" - local fzf_bin - fzf_bin="$(command -v fzf 2>/dev/null || true)" - - if [[ ! -d "$prompts_dir" ]]; then - printf "Prompts directory not found: %s\n" "$prompts_dir" >&2 - return 1 - fi - - if [[ -z "$fzf_bin" ]]; then - printf "fzf is not installed. Install: brew install fzf\n" >&2 - return 1 - fi - - local selected - selected="$( - find "$prompts_dir" -name "*.md" -not -name "INDEX.md" -not -name "README.md" -not -name "EXPORT_NOTES.md" -not -name "PROMPT_EXPORT_INDEX.md" | sort | while IFS= read -r f; do - label="$(basename "$(dirname "$f")" | sed 's/^[0-9]*_//')/$(basename "$f" .md | sed 's/_/ /g')" - printf "%s\t%s\n" "$label" "$f" - done \ - | "$fzf_bin" \ - --delimiter='\t' \ - --with-nth=1 \ - --preview='head -40 {2}' \ - --preview-window='right:55%:wrap' \ - --reverse \ - --border \ - --header='Select prompt → copy to clipboard (ESC = cancel)' \ - --prompt='prompt > ' \ - --height=80% \ - | cut -f2 - )" - - [[ -z "$selected" ]] && return 0 - - if command -v pbcopy >/dev/null 2>&1; then - pbcopy < "$selected" - printf "Copied to clipboard: %s\n" "$(basename "$selected" .txt | sed 's/_/ /g')" - else - printf "pbcopy not available — printing prompt:\n\n" - cat "$selected" - fi -} - # fzf interactive pickers (git log/branch, kill process/port, run snippet, # recent files) live in a dedicated library (Step 11a monolith de-layering, # audit P4). Sourced into this scope so they keep using the ambient UI helpers @@ -492,122 +407,11 @@ open_applications_folder() { open_folder_screen "OPEN APPLICATIONS FOLDER" "/Applications" "Applications folder missing:" } -# Opens ai prompts folder. -open_ai_prompts_folder() { - local target="" - target="$(resolve_prompt_dir 2>/dev/null || true)" - - print_header - row "OPEN AI PROMPTS FOLDER" - empty_row - - if [[ -n "$target" ]]; then - row "Opening:" - row " $target" - print_footer - open "$target" - else - row "Prompt dir missing." - row "Checked:" - row " $HOME/macos-scripts/ai-prompts" - row " $PROMPT_DIR" - print_footer - pause_enter - fi -} - -# Shows prompt files. -show_prompt_files() { - local resolved_prompt_dir="" - local -a files - local f - local shown=0 - - resolved_prompt_dir="$(resolve_prompt_dir 2>/dev/null || true)" - - print_header - row "PROMPT FILES" - empty_row - - if [[ -z "$resolved_prompt_dir" ]]; then - row "Prompt dir missing." - row "Checked:" - row " $HOME/macos-scripts/ai-prompts" - row " $PROMPT_DIR" - else - files=("$resolved_prompt_dir"/*(.N)) - if (( ${#files[@]} == 0 )); then - row "No prompt files found." - row "Folder:" - row " $resolved_prompt_dir" - else - for f in "${files[@]}"; do - row " - ${f:t}" - ((shown++)) - if (( shown >= 20 && ${#files[@]} > 20 )); then - row " ..." - break - fi - done - empty_row - row "Total files: ${#files[@]}" - row "Folder: $resolved_prompt_dir" - fi - fi - - print_footer - pause_enter -} - # Edits mqlaunch. edit_mqlaunch() { ${EDITOR:-nano} "$MQ_SCRIPT" } -# Backs up prompts. -backup_prompts() { - local resolved_prompt_dir="" - local stamp backup_file - - resolved_prompt_dir="$(resolve_prompt_dir 2>/dev/null || true)" - - if [[ -z "$resolved_prompt_dir" ]]; then - echo "${C_ERR}Prompt dir missing.${C_RESET}" - pause_enter - return - fi - - if ! command -v zip >/dev/null 2>&1; then - echo "${C_ERR}zip is missing on this system.${C_RESET}" - pause_enter - return - fi - - mkdir -p "$BACKUP_DIR" - stamp="$(date '+%Y%m%d-%H%M%S')" - backup_file="$BACKUP_DIR/ai-prompts-$stamp.zip" - - ( - cd "$(dirname "$resolved_prompt_dir")" || exit 1 - zip -rq "$backup_file" "$(basename "$resolved_prompt_dir")" - ) - - print_header - row "PROMPT BACKUP" - empty_row - - if [[ -f "$backup_file" ]]; then - row "Backup created successfully." - row "File:" - row " $backup_file" - else - row "Backup failed." - fi - - print_footer - pause_enter -} - # Opens base dir. open_base_dir() { open_folder_screen "OPEN MACOS-SCRIPTS FOLDER" "$BASE_DIR" "Base dir missing:" diff --git a/tests/monolith-delayer-smoke.sh b/tests/monolith-delayer-smoke.sh index 8f8673d..ad7e50a 100755 --- a/tests/monolith-delayer-smoke.sh +++ b/tests/monolith-delayer-smoke.sh @@ -20,6 +20,7 @@ CONCERNS=( "mqlaunch/lib/git-menus.sh : open_git_menu open_release_menu" "mqlaunch/lib/repo-picker.sh : run_github_repo_picker" "mqlaunch/lib/themes.sh : open_themes_menu theme_cmd theme_current_variant theme_source_state" + "mqlaunch/lib/prompts.sh : resolve_prompt_dir resolve_ai_status safe_run_ai prompts_pick show_prompt_files backup_prompts open_ai_prompts_folder" ) total=0 diff --git a/tests/prompt-lib-smoke.sh b/tests/prompt-lib-smoke.sh new file mode 100755 index 0000000..479ff26 --- /dev/null +++ b/tests/prompt-lib-smoke.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LIB="$ROOT/mqlaunch/lib/prompts.sh" +LAUNCHER="$ROOT/terminal/launchers/mqlaunch.sh" +TMPDIR_TEST="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_TEST"' EXIT + +fail() { printf '[FAIL] %s\n' "$1" >&2; exit 1; } + +echo "[1/5] shared prompt library exists and is sourced" +test -f "$LIB" || fail "prompt library missing" +grep -q 'mqlaunch/lib/prompts.sh' "$LAUNCHER" || fail "launcher does not source prompt library" + +echo "[2/5] prompt functions have one definition" +for function_name in resolve_prompt_dir resolve_ai_status safe_run_ai prompts_pick show_prompt_files backup_prompts open_ai_prompts_folder; do + count="$(grep -R -h "^${function_name}()" "$LIB" "$LAUNCHER" | wc -l | tr -d ' ')" + test "$count" = "1" || fail "$function_name has $count definitions" +done + +echo "[3/5] prompt directory resolution preserves precedence and failure" +mkdir -p "$TMPDIR_TEST/repo/ai-prompts" "$TMPDIR_TEST/configured" +output="$(BASE_DIR="$TMPDIR_TEST/repo" PROMPT_DIR="$TMPDIR_TEST/configured" zsh -c 'source "$1"; resolve_prompt_dir' _ "$LIB")" +test "$output" = "$TMPDIR_TEST/repo/ai-prompts" || fail "repo prompt directory did not win" +rm -rf "$TMPDIR_TEST/repo/ai-prompts" +output="$(BASE_DIR="$TMPDIR_TEST/repo" PROMPT_DIR="$TMPDIR_TEST/configured" zsh -c 'source "$1"; resolve_prompt_dir' _ "$LIB")" +test "$output" = "$TMPDIR_TEST/configured" || fail "configured prompt directory was not used" +rm -rf "$TMPDIR_TEST/configured" +if BASE_DIR="$TMPDIR_TEST/repo" PROMPT_DIR="$TMPDIR_TEST/missing" zsh -c 'source "$1"; resolve_prompt_dir' _ "$LIB" >/dev/null 2>&1; then + fail "missing prompt directories returned success" +fi + +echo "[4/5] AI status preserves all backend states" +AI_SCRIPT="$TMPDIR_TEST/ai-mode.sh" +output="$(AI_SCRIPT="$AI_SCRIPT" zsh -c 'source "$1"; resolve_ai_status' _ "$LIB")" +test "$output" = "MISSING" || fail "missing backend status changed" +touch "$AI_SCRIPT" +output="$(AI_SCRIPT="$AI_SCRIPT" zsh -c 'source "$1"; resolve_ai_status' _ "$LIB")" +test "$output" = "FOUND_NOT_EXECUTABLE" || fail "non-executable backend status changed" +chmod +x "$AI_SCRIPT" +output="$(AI_SCRIPT="$AI_SCRIPT" zsh -c 'source "$1"; resolve_ai_status' _ "$LIB")" +test "$output" = "OK" || fail "executable backend status changed" + +echo "[5/5] AI mode is forwarded unchanged" +printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$@"\n' > "$AI_SCRIPT" +chmod +x "$AI_SCRIPT" +output="$(AI_SCRIPT="$AI_SCRIPT" zsh -c 'source "$1"; safe_run_ai research' _ "$LIB")" +test "$output" = "research" || fail "AI mode forwarding changed" + +echo "OK: shared prompt concern" diff --git a/tools/scripts/test-all.sh b/tools/scripts/test-all.sh index 23c1e73..0f4a041 100755 --- a/tools/scripts/test-all.sh +++ b/tools/scripts/test-all.sh @@ -20,6 +20,7 @@ echo "== Running mqlaunch headless checks ==" "$PROJECT_ROOT/tests/mq-debug-smoke.sh" "$PROJECT_ROOT/tests/monolith-delayer-smoke.sh" "$PROJECT_ROOT/tests/theme-lib-smoke.sh" +"$PROJECT_ROOT/tests/prompt-lib-smoke.sh" "$PROJECT_ROOT/tests/mq-stack-contract-smoke.sh" "$PROJECT_ROOT/tests/gitmerge-safe-smoke.sh" "$PROJECT_ROOT/tests/dashboard-header-cache-smoke.sh"