From da20cb851ba8061a8934e1eb1ec3ed672be504d0 Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 22 Dec 2025 14:26:15 -0800 Subject: [PATCH 01/18] Fix weather geolocation and make word of day fully offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weather improvements: - Require explicit GOODMORNING_WEATHER_LOCATION config (IP geolocation unreliable) - Add location prompt to setup.sh with automatic lowercase conversion - Display temperature in Fahrenheit (US units) - Update doctor.sh to warn when location not configured Word of the day improvements: - Replace API-based approach with macOS built-in dictionary (fully offline) - Use Swift/CoreServices to access DCSCopyTextDefinition - Select word from /usr/share/dict/words based on day of year - Remove curl/jq dependencies for this section New --section flag: - Add --section NAME to run individual sections without preflight/updates - Useful for testing and quick lookups (e.g., --section weather) - Fix zparseopts boolean checks using ${+opts[key]} syntax 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 1 + goodmorning.sh | 65 ++++++++++++--- lib/app/display.sh | 16 +++- lib/app/sections/word_of_day.sh | 135 +++++++++++++++----------------- lib/setup/doctor.sh | 8 +- setup.sh | 12 +++ spec/lib/display_spec.sh | 24 ++++++ 7 files changed, 176 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 70ddb02..57a22d1 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,7 @@ Configuration is managed through environment variables. The setup script handles | `GOODMORNING_LOGS_DIR` | Directory for log files | `$GOODMORNING_CONFIG_DIR/logs` | No | | `GOODMORNING_OUTPUT_HISTORY_DIR` | Directory for output history | `$GOODMORNING_CONFIG_DIR/output_history` | No | | `GOODMORNING_SHOW_WEATHER` | Show weather section | `true` | No | +| `GOODMORNING_WEATHER_LOCATION` | City for weather (e.g., San_Francisco, London) | (none) | **Yes** | | `GOODMORNING_SHOW_HISTORY` | Show history section | `true` | No | | `GOODMORNING_SHOW_LEARNING` | Show daily learning section | `true` | No | | `GOODMORNING_SHOW_SANITY` | Show sanity maintenance section | `true` | No | diff --git a/goodmorning.sh b/goodmorning.sh index 8552396..2e61dd7 100755 --- a/goodmorning.sh +++ b/goodmorning.sh @@ -169,6 +169,7 @@ OPEN_LINKS="${GOODMORNING_OPEN_LINKS:-true}" # Feature flags - briefing sections (all enabled by default) SHOW_WEATHER="${GOODMORNING_SHOW_WEATHER:-true}" +WEATHER_LOCATION="${GOODMORNING_WEATHER_LOCATION:-}" SHOW_HISTORY="${GOODMORNING_SHOW_HISTORY:-true}" SHOW_TECH_VERSIONS="${GOODMORNING_SHOW_TECH_VERSIONS:-true}" SHOW_COUNTRY="${GOODMORNING_SHOW_COUNTRY:-true}" @@ -221,32 +222,78 @@ _setup_output_history() { OUTPUT_HISTORY_FILE="$day_dir/goodmorning-${count}.txt" } +# Run a single section without preflight checks or background updates +_run_single_section() { + local section="$1" + + # Map section names to functions + case "$section" in + weather) show_weather ;; + history) show_history ;; + tech-versions) show_tech_versions ;; + country) show_country_of_day ;; + word) show_word_of_day ;; + wikipedia) show_wikipedia_featured ;; + apod) show_apod ;; + cat) show_cat_of_day ;; + calendar) show_calendar ;; + reminders) show_reminders ;; + github) show_github_notifications ;; + github-prs) show_github_prs ;; + github-issues) show_github_issues ;; + alias-suggestions) show_alias_suggestions ;; + system-info) show_system_info ;; + learning) show_daily_learning ;; + sanity) show_sanity_maintenance ;; + tips) show_learning_tips ;; + *) + echo_error "Unknown section: $section" + echo "Run with --help to see available sections" + return 1 + ;; + esac +} + main() { # Load zsh utilities module for zparseopts zmodload zsh/zutil # Parse command line arguments using zparseopts local -A opts - zparseopts -D -E -A opts -- \ + local -a section_arg + zparseopts -D -E -A opts -section:=section_arg -- \ h -help \ -noisy \ -doctor \ -offline # Handle help - if [[ -n "${opts[--help]}" || -n "${opts[-h]}" ]]; then + if (( ${+opts[--help]} || ${+opts[-h]} )); then echo "Usage: goodmorning.sh [OPTIONS]" show_new_line echo "Options:" - echo " --noisy Enable text-to-speech greeting" - echo " --doctor Run system diagnostics and validation" - echo " --offline Run in offline mode (skip network features)" - echo " -h, --help Show this help message" + echo " --section NAME Run a single section (skips preflight/updates)" + echo " --noisy Enable text-to-speech greeting" + echo " --doctor Run system diagnostics and validation" + echo " --offline Run in offline mode (skip network features)" + echo " -h, --help Show this help message" + show_new_line + echo "Available sections:" + echo " weather, history, tech-versions, country, word, wikipedia, apod," + echo " cat, calendar, reminders, github, github-prs, github-issues," + echo " alias-suggestions, system-info, learning, sanity, tips" return 0 fi + # Handle single section mode (skip all preflight and run just one function) + if [[ ${#section_arg[@]} -gt 0 ]]; then + local section_name="${section_arg[2]}" + _run_single_section "$section_name" + return $? + fi + # Handle doctor mode - if [[ -n "${opts[--doctor]}" ]]; then + if (( ${+opts[--doctor]} )); then # Source validation for doctor functions if [ -f "$SCRIPT_DIR/lib/validation.sh" ]; then source "$SCRIPT_DIR/lib/validation.sh" @@ -259,12 +306,12 @@ main() { fi # Handle offline mode - if [[ -n "${opts[--offline]}" ]]; then + if (( ${+opts[--offline]} )); then export GOODMORNING_FORCE_OFFLINE=true fi # Handle TTS mode - if [[ -n "${opts[--noisy]}" ]]; then + if (( ${+opts[--noisy]} )); then export GOODMORNING_ENABLE_TTS=true fi diff --git a/lib/app/display.sh b/lib/app/display.sh index bf81adb..44ae39f 100644 --- a/lib/app/display.sh +++ b/lib/app/display.sh @@ -33,11 +33,21 @@ show_banner() { show_weather() { print_section "🌤️ Weather:" "yellow" - local weather=$(fetch_with_spinner "Fetching weather..." curl -s --max-time 10 "wttr.in/?format=3") - if [ -n "$weather" ]; then + local location="${GOODMORNING_WEATHER_LOCATION:-}" + + if [ -z "$location" ]; then + show_setup_message "Set GOODMORNING_WEATHER_LOCATION to your city (e.g., San_Francisco)" + show_new_line + return 0 + fi + + local url="wttr.in/${location}?format=3&u" + local weather=$(fetch_with_spinner "Fetching weather..." curl -s --max-time 10 "$url") + + if [ -n "$weather" ] && [[ "$weather" != "not found:"* ]]; then echo "$weather" else - echo "Weather unavailable" + echo "Weather unavailable for: $location" fi show_new_line } diff --git a/lib/app/sections/word_of_day.sh b/lib/app/sections/word_of_day.sh index d36ef4b..7d3b57d 100755 --- a/lib/app/sections/word_of_day.sh +++ b/lib/app/sections/word_of_day.sh @@ -3,115 +3,108 @@ ############################################################################### # Word of the Day Section # -# Displays word definitions using Free Dictionary API +# Displays word definitions using macOS built-in dictionary (fully offline) ############################################################################### -register_section "word_of_day" --tools "curl" "jq" --network +register_section "word_of_day" --tools "swift" ############################################################################### -# fetch_word_of_day - Try words until one has a definition in the API +# fetch_word_of_day - Get word and definition from macOS dictionary (offline) # -# Uses day-based selection from system dictionary, iterating through candidates -# until finding one that exists in the Free Dictionary API. +# Uses Swift to access macOS CoreServices dictionary. Selects an interesting +# word from /usr/share/dict/words based on day of year. ############################################################################### fetch_word_of_day() { local dict_file="/usr/share/dict/words" local day_of_year=$(date +%j | sed 's/^0*//') - local max_attempts=10 - - # Get candidate words based on day of year - local candidates=() - if [[ -f "$dict_file" ]]; then - # Get multiple candidates by offsetting from the day - for offset in 0 1 2 3 4 5 6 7 8 9; do - local idx=$(( (day_of_year + offset) % 50 )) - local word=$(grep -E '^[a-z]{7,12}$' "$dict_file" | awk "NR % 50 == $idx" | head -1) - [[ -n "$word" ]] && candidates+=("$word") + + # Select a word: filter for interesting words (7-12 chars, lowercase only) + # Use day of year to pick consistently for the day + local word + word=$(grep -E '^[a-z]{7,12}$' "$dict_file" 2>/dev/null | awk "NR == ($day_of_year * 7) % 5000 + 1" | head -1) + + # Fallback words if dict file fails + [[ -z "$word" ]] && word="ephemeral" + + # Use Swift to get definition from macOS dictionary + local definition + definition=$(swift -e " +import Foundation +import CoreServices +let word = \"$word\" +if let def = DCSCopyTextDefinition(nil, word as CFString, CFRangeMake(0, word.count))?.takeRetainedValue() as String? { + print(def) +} +" 2>> "$LOG_FILE") + + # If no definition found, try fallback words + if [[ -z "$definition" ]]; then + for fallback in "ephemeral" "serendipity" "eloquent" "resilient" "catalyst"; do + definition=$(swift -e " +import Foundation +import CoreServices +let word = \"$fallback\" +if let def = DCSCopyTextDefinition(nil, word as CFString, CFRangeMake(0, word.count))?.takeRetainedValue() as String? { + print(def) +} +" 2>> "$LOG_FILE") + [[ -n "$definition" ]] && word="$fallback" && break done fi - # Add fallbacks - candidates+=("ephemeral" "serendipity" "eloquent" "resilient" "catalyst") + [[ -z "$definition" ]] && return 1 - # Try each candidate until one works - for word in "${candidates[@]}"; do - local word_data - local retry + # Parse macOS dictionary format: "word syllables | phonetic | part of speech definition..." + local phonetic part_of_speech def_text - # Retry up to 2 times per word with increasing timeout - for retry in 1 2; do - word_data=$(fetch_url "https://api.dictionaryapi.dev/api/v2/entries/en/$word" $((retry * 5))) - [[ -n "$word_data" ]] && break - sleep $((RANDOM % 3 + 1)) - done + # Extract phonetic (between | symbols) + phonetic=$(echo "$definition" | grep -o '| [^|]* |' | head -1 | sed 's/|//g' | xargs) + + # Extract part of speech (first occurrence) + part_of_speech=$(echo "$definition" | grep -oE '\b(noun|verb|adjective|adverb)\b' | head -1) - # Check if API returned a valid definition (not an error) - if [[ -n "$word_data" ]] && ! echo "$word_data" | grep -q '"title":"No Definitions Found"'; then - local fetched_word=$(jq_extract "$word_data" '.[0].word') - [[ -z "$fetched_word" ]] && continue - - local phonetic=$(printf '%s' "$word_data" | jq -r '.[0].phonetic // .[0].phonetics[0].text // ""' 2>> "$LOG_FILE") - local part_of_speech=$(jq_extract "$word_data" '.[0].meanings[0].partOfSpeech') - local definition=$(jq_extract "$word_data" '.[0].meanings[0].definitions[0].definition') - local example=$(printf '%s' "$word_data" | jq -r '.[0].meanings[0].definitions[0].example // ""' 2>> "$LOG_FILE") - - jq -n \ - --arg word "$fetched_word" \ - --arg phonetic "$phonetic" \ - --arg pos "$part_of_speech" \ - --arg def "$definition" \ - --arg ex "$example" \ - '{word: $word, phonetic: $phonetic, partOfSpeech: $pos, definition: $def, example: $ex}' - return 0 - fi - done - - return 1 + # Extract just the definition text + # Remove everything up to and including the part of speech, stop at DERIVATIVES/ORIGIN + def_text=$(echo "$definition" | sed -E "s/^.*$part_of_speech //" | sed -E 's/(DERIVATIVES|ORIGIN|PHRASES|•).*$//' | head -c 250 | xargs) + + # Output as simple tab-separated values + printf '%s\t%s\t%s\t%s\n' "$word" "$phonetic" "$part_of_speech" "$def_text" } show_word_of_day() { print_section "Word of the Day" "cyan" - local word_data=$(fetch_with_spinner "Fetching word..." fetch_word_of_day) + local word_data + word_data=$(fetch_with_spinner "Looking up word..." fetch_word_of_day) - if [ -z "$word_data" ]; then + if [[ -z "$word_data" ]]; then show_setup_message "$(echo_yellow ' ⚠ Could not fetch word of the day')" return 0 fi - local word=$(jq_extract "$word_data" '.word') - local phonetic=$(jq_extract "$word_data" '.phonetic') - local part_of_speech=$(jq_extract "$word_data" '.partOfSpeech') - local definition=$(jq_extract "$word_data" '.definition') - local example=$(jq_extract "$word_data" '.example') - - word=$(safe_display "$word" "") - definition=$(safe_display "$definition" "") + # Parse tab-separated values: word, phonetic, part_of_speech, definition + local word phonetic part_of_speech definition + word=$(echo "$word_data" | cut -f1) + phonetic=$(echo "$word_data" | cut -f2) + part_of_speech=$(echo "$word_data" | cut -f3) + definition=$(echo "$word_data" | cut -f4-) - if [ -z "$word" ] || [ -z "$definition" ]; then + if [[ -z "$word" ]] || [[ -z "$definition" ]]; then show_setup_message "$(echo_yellow ' ⚠ Word data unavailable')" return 0 fi show_new_line - phonetic=$(safe_display "$phonetic" "") - if [ -n "$phonetic" ]; then + if [[ -n "$phonetic" ]]; then echo_cyan " 📖 $(echo_green "$word") $(echo_gray "$phonetic")" else echo_cyan " 📖 $(echo_green "$word")" fi - part_of_speech=$(safe_display "$part_of_speech" "") - if [ -n "$part_of_speech" ]; then + if [[ -n "$part_of_speech" ]]; then echo_gray " $part_of_speech" fi show_new_line - echo " $definition" | fold -s -w 70 | sed 's/^/ /' - - example=$(safe_display "$example" "") - if [ -n "$example" ] && [ "$example" != "N/A" ]; then - show_new_line - echo_gray " Example: \"$example\"" - fi + echo "$definition" | fold -s -w 70 | sed 's/^/ /' show_new_line } diff --git a/lib/setup/doctor.sh b/lib/setup/doctor.sh index 016fd5f..a14bff0 100755 --- a/lib/setup/doctor.sh +++ b/lib/setup/doctor.sh @@ -348,9 +348,13 @@ doctor_check_configured_paths() { doctor_check_api_keys() { validation_section "🔑 API Keys & Authentication" - # Weather uses wttr.in (free, no API key required) + # Weather uses wttr.in (free, requires location to be configured) if [[ "${GOODMORNING_SHOW_WEATHER:-true}" == "true" ]]; then - validation_pass "Weather: uses wttr.in (no API key required)" + if [[ -n "${GOODMORNING_WEATHER_LOCATION:-}" ]]; then + validation_pass "Weather: configured for ${GOODMORNING_WEATHER_LOCATION}" + else + validation_warn "Weather: GOODMORNING_WEATHER_LOCATION not set" "Set to your city (e.g., San_Francisco)" + fi else validation_info "Weather: disabled in config" fi diff --git a/setup.sh b/setup.sh index fdcb6ac..90b17c9 100755 --- a/setup.sh +++ b/setup.sh @@ -599,6 +599,17 @@ setup_section_features() { read -r response [[ -z "$response" || "$response" =~ ^[Yy]$ ]] && SETUP_SHOW_WEATHER="true" || SETUP_SHOW_WEATHER="false" + if [[ "$SETUP_SHOW_WEATHER" == "true" ]]; then + show_new_line + echo_gray " Weather requires a location (IP geolocation is unreliable)." + echo_gray " Use underscores for spaces (e.g., san_francisco, new_york)" + show_new_line + echo_green -n " Your city: " + read -r location_input + # Lowercase the location for wttr.in compatibility + SETUP_WEATHER_LOCATION="${location_input:l}" + fi + show_new_line section_box "📜 On This Day in History" show_new_line @@ -901,6 +912,7 @@ EOF # Briefing feature flags export GOODMORNING_SHOW_SETUP_MESSAGES="${SETUP_SHOW_SETUP_MESSAGES:-true}" export GOODMORNING_SHOW_WEATHER="${SETUP_SHOW_WEATHER:-true}" +export GOODMORNING_WEATHER_LOCATION="${SETUP_WEATHER_LOCATION:-}" export GOODMORNING_SHOW_HISTORY="${SETUP_SHOW_HISTORY:-true}" export GOODMORNING_SHOW_TECH_VERSIONS="${SETUP_SHOW_TECH_VERSIONS:-true}" export GOODMORNING_SHOW_SYSTEM_INFO="${SETUP_SHOW_SYSTEM_INFO:-true}" diff --git a/spec/lib/display_spec.sh b/spec/lib/display_spec.sh index aae722c..13e11f1 100644 --- a/spec/lib/display_spec.sh +++ b/spec/lib/display_spec.sh @@ -50,6 +50,30 @@ Describe 'lib/app/display.sh - Display Functions' The status should be success The output should not be blank End + + It 'requires GOODMORNING_WEATHER_LOCATION to be set' + When call grep 'GOODMORNING_WEATHER_LOCATION' "$PROJECT_ROOT/lib/app/display.sh" + The status should be success + The output should not be blank + End + + It 'uses location in wttr.in URL' + When call grep 'wttr.in/\${location}' "$PROJECT_ROOT/lib/app/display.sh" + The status should be success + The output should not be blank + End + + It 'shows setup message when location not configured' + When call grep 'show_setup_message.*GOODMORNING_WEATHER_LOCATION' "$PROJECT_ROOT/lib/app/display.sh" + The status should be success + The output should not be blank + End + + It 'handles not found response from API' + When call grep 'not found:' "$PROJECT_ROOT/lib/app/display.sh" + The status should be success + The output should not be blank + End End Describe 'show_history function' From 40c72f61731a80491d2e55162aa43fa321abc94c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:45:32 +0000 Subject: [PATCH 02/18] Initial plan From f15a0e5614e7e32ac2f9709280363cee8b947e30 Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 22 Dec 2025 14:45:43 -0800 Subject: [PATCH 03/18] Update lib/app/sections/word_of_day.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/app/sections/word_of_day.sh | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/app/sections/word_of_day.sh b/lib/app/sections/word_of_day.sh index 7d3b57d..63850a2 100755 --- a/lib/app/sections/word_of_day.sh +++ b/lib/app/sections/word_of_day.sh @@ -28,26 +28,30 @@ fetch_word_of_day() { # Use Swift to get definition from macOS dictionary local definition - definition=$(swift -e " + definition=$(swift -e ' import Foundation import CoreServices -let word = \"$word\" +let args = CommandLine.arguments +guard args.count > 1 else { exit(1) } +let word = args[1] if let def = DCSCopyTextDefinition(nil, word as CFString, CFRangeMake(0, word.count))?.takeRetainedValue() as String? { print(def) } -" 2>> "$LOG_FILE") +' -- "$word" 2>> "$LOG_FILE") # If no definition found, try fallback words if [[ -z "$definition" ]]; then for fallback in "ephemeral" "serendipity" "eloquent" "resilient" "catalyst"; do - definition=$(swift -e " + definition=$(swift -e ' import Foundation import CoreServices -let word = \"$fallback\" +let args = CommandLine.arguments +guard args.count > 1 else { exit(1) } +let word = args[1] if let def = DCSCopyTextDefinition(nil, word as CFString, CFRangeMake(0, word.count))?.takeRetainedValue() as String? { print(def) } -" 2>> "$LOG_FILE") +' -- "$fallback" 2>> "$LOG_FILE") [[ -n "$definition" ]] && word="$fallback" && break done fi From f14e2c807c0d36b4919e00ddab92845b32d67e46 Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 22 Dec 2025 14:46:01 -0800 Subject: [PATCH 04/18] Update goodmorning.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- goodmorning.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/goodmorning.sh b/goodmorning.sh index 2e61dd7..943be12 100755 --- a/goodmorning.sh +++ b/goodmorning.sh @@ -169,7 +169,7 @@ OPEN_LINKS="${GOODMORNING_OPEN_LINKS:-true}" # Feature flags - briefing sections (all enabled by default) SHOW_WEATHER="${GOODMORNING_SHOW_WEATHER:-true}" -WEATHER_LOCATION="${GOODMORNING_WEATHER_LOCATION:-}" +GOODMORNING_WEATHER_LOCATION="${GOODMORNING_WEATHER_LOCATION:-}" SHOW_HISTORY="${GOODMORNING_SHOW_HISTORY:-true}" SHOW_TECH_VERSIONS="${GOODMORNING_SHOW_TECH_VERSIONS:-true}" SHOW_COUNTRY="${GOODMORNING_SHOW_COUNTRY:-true}" From e988ed1984d7427da5e754afc8cba5e38f7c2f68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:46:22 +0000 Subject: [PATCH 05/18] Initial plan From 7ab96dc7add8ee4e4e218143a4b21e742de522b9 Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 22 Dec 2025 14:46:31 -0800 Subject: [PATCH 06/18] Update lib/app/sections/word_of_day.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/app/sections/word_of_day.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/app/sections/word_of_day.sh b/lib/app/sections/word_of_day.sh index 63850a2..dd4df89 100755 --- a/lib/app/sections/word_of_day.sh +++ b/lib/app/sections/word_of_day.sh @@ -109,6 +109,6 @@ show_word_of_day() { echo_gray " $part_of_speech" fi show_new_line - echo "$definition" | fold -s -w 70 | sed 's/^/ /' + echo "$definition" | fold -s -w 70 | sed 's/^/ /' show_new_line } From 9e44650fa240d4688ea12edad1d805c49e6ae0cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:46:56 +0000 Subject: [PATCH 07/18] Initial plan From ada7e519223c70ffd0c39d3e93a74dae16cb0013 Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 22 Dec 2025 14:47:01 -0800 Subject: [PATCH 08/18] Update lib/app/display.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/app/display.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/app/display.sh b/lib/app/display.sh index 44ae39f..8e33e5a 100644 --- a/lib/app/display.sh +++ b/lib/app/display.sh @@ -41,6 +41,13 @@ show_weather() { return 0 fi + # Validate location to avoid unsafe characters in the URL + if ! [[ "$location" =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "Invalid weather location: '$location'" >&2 + echo "Use only letters, numbers, underscores, and hyphens (e.g., San_Francisco)." >&2 + show_new_line + return 1 + fi local url="wttr.in/${location}?format=3&u" local weather=$(fetch_with_spinner "Fetching weather..." curl -s --max-time 10 "$url") From fd2af2d33070966a4ba6167c69e594629a39fd93 Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 22 Dec 2025 14:47:10 -0800 Subject: [PATCH 09/18] Update lib/app/sections/word_of_day.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/app/sections/word_of_day.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/app/sections/word_of_day.sh b/lib/app/sections/word_of_day.sh index dd4df89..efe80e0 100755 --- a/lib/app/sections/word_of_day.sh +++ b/lib/app/sections/word_of_day.sh @@ -21,9 +21,18 @@ fetch_word_of_day() { # Select a word: filter for interesting words (7-12 chars, lowercase only) # Use day of year to pick consistently for the day local word - word=$(grep -E '^[a-z]{7,12}$' "$dict_file" 2>/dev/null | awk "NR == ($day_of_year * 7) % 5000 + 1" | head -1) - # Fallback words if dict file fails + # Count matching words to ensure selection index is within bounds + local total_words selected_index + total_words=$(grep -E '^[a-z]{7,12}$' "$dict_file" 2>/dev/null | wc -l | tr -d ' ') + + if [[ -n "$total_words" && "$total_words" -gt 0 ]]; then + # Compute a stable index in the range [1, total_words] + selected_index=$(( (day_of_year * 7) % total_words + 1 )) + word=$(grep -E '^[a-z]{7,12}$' "$dict_file" 2>/dev/null | awk "NR == $selected_index" | head -1) + fi + + # Fallback word if dict file fails or selection produced no word [[ -z "$word" ]] && word="ephemeral" # Use Swift to get definition from macOS dictionary From e6faffcba4d72f34af82b437f61ad16e6f4edbd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:47:35 +0000 Subject: [PATCH 10/18] Initial plan From 954558b41048905104a51bf9a4c53bb02869f45a Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 22 Dec 2025 14:47:35 -0800 Subject: [PATCH 11/18] Update lib/app/sections/word_of_day.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/app/sections/word_of_day.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/app/sections/word_of_day.sh b/lib/app/sections/word_of_day.sh index efe80e0..8cef4a0 100755 --- a/lib/app/sections/word_of_day.sh +++ b/lib/app/sections/word_of_day.sh @@ -91,7 +91,7 @@ show_word_of_day() { word_data=$(fetch_with_spinner "Looking up word..." fetch_word_of_day) if [[ -z "$word_data" ]]; then - show_setup_message "$(echo_yellow ' ⚠ Could not fetch word of the day')" + show_setup_message "$(echo_yellow ' ⚠ Could not find word definition in dictionary')" return 0 fi From 9a3b70d82304b4d873846a763e4478bfda7a66ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:48:37 +0000 Subject: [PATCH 12/18] Add documentation for --section flag in README Co-authored-by: bgreg <3711139+bgreg@users.noreply.github.com> --- README.md | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 57a22d1..ce91c39 100644 --- a/README.md +++ b/README.md @@ -279,12 +279,41 @@ brew install figlet ### Command Line Options ```bash -./goodmorning.sh # Run with default settings -./goodmorning.sh --noisy # Enable text-to-speech greeting -./goodmorning.sh --doctor # Run system diagnostics and validation -./goodmorning.sh --help # Show help message +./goodmorning.sh # Run with default settings +./goodmorning.sh --noisy # Enable text-to-speech greeting +./goodmorning.sh --doctor # Run system diagnostics and validation +./goodmorning.sh --section cat # Run a single section (skips preflight/updates) +./goodmorning.sh --help # Show help message ``` +**Running Individual Sections:** +You can run a single section without preflight checks or background updates: +```bash +./goodmorning.sh --section weather +./goodmorning.sh --section word +./goodmorning.sh --section github-prs +``` + +Available sections: +- `weather` - Current weather conditions +- `history` - Historical events for today +- `tech-versions` - Latest versions of tools and technologies +- `country` - Random country of the day +- `word` - Word of the day with definition +- `wikipedia` - Wikipedia featured article +- `apod` - NASA Astronomy Picture of the Day +- `cat` - Random cat picture +- `calendar` - Today's calendar events +- `reminders` - Upcoming reminders +- `github` - GitHub notifications +- `github-prs` - GitHub pull requests requiring review +- `github-issues` - GitHub issues assigned to you +- `alias-suggestions` - Shell alias suggestions +- `system-info` - System information and updates +- `learning` - Daily learning resource +- `sanity` - Sanity maintenance tips +- `tips` - AI-powered learning tips + **Text-to-Speech:** By default, the spoken "Good morning" greeting is disabled. Enable it with: - Runtime flag: `./goodmorning.sh --noisy` From 484601f67e8acd4b16bd4872c720c0f888af76d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:48:39 +0000 Subject: [PATCH 13/18] Make zparseopts section extraction more robust Use last element instead of hardcoded index 2, add validation for array length, and provide clear error message when --section value is missing. Co-authored-by: bgreg <3711139+bgreg@users.noreply.github.com> --- goodmorning.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/goodmorning.sh b/goodmorning.sh index 2e61dd7..f110b4b 100755 --- a/goodmorning.sh +++ b/goodmorning.sh @@ -287,7 +287,13 @@ main() { # Handle single section mode (skip all preflight and run just one function) if [[ ${#section_arg[@]} -gt 0 ]]; then - local section_name="${section_arg[2]}" + # zparseopts stores option name at index 1 and value at index 2 + # Use the last element to be more robust against zparseopts behavior changes + if [[ ${#section_arg[@]} -lt 2 ]]; then + echo "Error: --section requires a value" >&2 + return 1 + fi + local section_name="${section_arg[-1]}" _run_single_section "$section_name" return $? fi From 18ebb038d6762d32000af3339af73a80ad3f27ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:51:28 +0000 Subject: [PATCH 14/18] Optimize word of day fallback to use single Swift invocation Co-authored-by: bgreg <3711139+bgreg@users.noreply.github.com> --- lib/app/sections/word_of_day.sh | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/lib/app/sections/word_of_day.sh b/lib/app/sections/word_of_day.sh index efe80e0..0d0ca4f 100755 --- a/lib/app/sections/word_of_day.sh +++ b/lib/app/sections/word_of_day.sh @@ -48,21 +48,27 @@ if let def = DCSCopyTextDefinition(nil, word as CFString, CFRangeMake(0, word.co } ' -- "$word" 2>> "$LOG_FILE") - # If no definition found, try fallback words + # If no definition found, try fallback words in a single Swift invocation if [[ -z "$definition" ]]; then - for fallback in "ephemeral" "serendipity" "eloquent" "resilient" "catalyst"; do - definition=$(swift -e ' + local fallback_result + fallback_result=$(swift -e ' import Foundation import CoreServices -let args = CommandLine.arguments -guard args.count > 1 else { exit(1) } -let word = args[1] -if let def = DCSCopyTextDefinition(nil, word as CFString, CFRangeMake(0, word.count))?.takeRetainedValue() as String? { - print(def) +let fallbackWords = ["ephemeral", "serendipity", "eloquent", "resilient", "catalyst"] +for word in fallbackWords { + if let def = DCSCopyTextDefinition(nil, word as CFString, CFRangeMake(0, word.count))?.takeRetainedValue() as String? { + print(word) + print("---DEFINITION---") + print(def) + break + } } -' -- "$fallback" 2>> "$LOG_FILE") - [[ -n "$definition" ]] && word="$fallback" && break - done +' 2>> "$LOG_FILE") + + if [[ -n "$fallback_result" ]]; then + word=$(echo "$fallback_result" | head -1) + definition=$(echo "$fallback_result" | sed '1,/---DEFINITION---/d') + fi fi [[ -z "$definition" ]] && return 1 From 4d6191245f8fc881e1989b5e60b5c8ce60785aa8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:52:23 +0000 Subject: [PATCH 15/18] Address code review feedback: fix whitespace and improve parsing Co-authored-by: bgreg <3711139+bgreg@users.noreply.github.com> --- lib/app/sections/word_of_day.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/app/sections/word_of_day.sh b/lib/app/sections/word_of_day.sh index 0d0ca4f..4ffe85f 100755 --- a/lib/app/sections/word_of_day.sh +++ b/lib/app/sections/word_of_day.sh @@ -58,16 +58,16 @@ let fallbackWords = ["ephemeral", "serendipity", "eloquent", "resilient", "catal for word in fallbackWords { if let def = DCSCopyTextDefinition(nil, word as CFString, CFRangeMake(0, word.count))?.takeRetainedValue() as String? { print(word) - print("---DEFINITION---") + print("<<>>") print(def) break } } ' 2>> "$LOG_FILE") - + if [[ -n "$fallback_result" ]]; then - word=$(echo "$fallback_result" | head -1) - definition=$(echo "$fallback_result" | sed '1,/---DEFINITION---/d') + word=$(echo "$fallback_result" | awk '/<<>>/ {exit} {print}' | tr -d '\n') + definition=$(echo "$fallback_result" | awk '/<<>>/ {flag=1; next} flag') fi fi From b00d9026c307eca0f62b6936cb26b760bcde7e95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:53:24 +0000 Subject: [PATCH 16/18] Improve awk parsing patterns for better robustness Co-authored-by: bgreg <3711139+bgreg@users.noreply.github.com> --- lib/app/sections/word_of_day.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/app/sections/word_of_day.sh b/lib/app/sections/word_of_day.sh index 4ffe85f..716eba2 100755 --- a/lib/app/sections/word_of_day.sh +++ b/lib/app/sections/word_of_day.sh @@ -66,8 +66,8 @@ for word in fallbackWords { ' 2>> "$LOG_FILE") if [[ -n "$fallback_result" ]]; then - word=$(echo "$fallback_result" | awk '/<<>>/ {exit} {print}' | tr -d '\n') - definition=$(echo "$fallback_result" | awk '/<<>>/ {flag=1; next} flag') + word=$(echo "$fallback_result" | awk 'BEGIN {found=0} /<<>>/ {found=1; exit} !found {print}' | tr -d '\n') + definition=$(echo "$fallback_result" | awk 'BEGIN {found=0} /<<>>/ {found=1; next} found {print}') fi fi From 15d9ad452c3217fcde29dec8b34d56f6e6749a86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:57:57 +0000 Subject: [PATCH 17/18] Replace grep-based weather tests with runtime behavior tests Co-authored-by: bgreg <3711139+bgreg@users.noreply.github.com> --- spec/lib/display_spec.sh | 125 ++++++++++++++++++++++++++++++++------- 1 file changed, 103 insertions(+), 22 deletions(-) diff --git a/spec/lib/display_spec.sh b/spec/lib/display_spec.sh index 13e11f1..e02bc02 100644 --- a/spec/lib/display_spec.sh +++ b/spec/lib/display_spec.sh @@ -51,28 +51,109 @@ Describe 'lib/app/display.sh - Display Functions' The output should not be blank End - It 'requires GOODMORNING_WEATHER_LOCATION to be set' - When call grep 'GOODMORNING_WEATHER_LOCATION' "$PROJECT_ROOT/lib/app/display.sh" - The status should be success - The output should not be blank - End - - It 'uses location in wttr.in URL' - When call grep 'wttr.in/\${location}' "$PROJECT_ROOT/lib/app/display.sh" - The status should be success - The output should not be blank - End - - It 'shows setup message when location not configured' - When call grep 'show_setup_message.*GOODMORNING_WEATHER_LOCATION' "$PROJECT_ROOT/lib/app/display.sh" - The status should be success - The output should not be blank - End - - It 'handles not found response from API' - When call grep 'not found:' "$PROJECT_ROOT/lib/app/display.sh" - The status should be success - The output should not be blank + # Integration tests with mocked fetch_with_spinner + It 'returns early when GOODMORNING_WEATHER_LOCATION is not set' + GOODMORNING_WEATHER_LOCATION="" + SHOW_SETUP_MESSAGES="true" + When call show_weather + The status should be success + The output should include "Set GOODMORNING_WEATHER_LOCATION" + End + + Context 'with mocked fetch_with_spinner' + setup_basic_mock() { + fetch_with_spinner() { + shift # Skip the message parameter + echo "$MOCK_WEATHER_RESPONSE" + } + } + + Before 'setup_basic_mock' + + It 'successfully fetches weather when location is set' + GOODMORNING_WEATHER_LOCATION="San_Francisco" + MOCK_WEATHER_RESPONSE="San Francisco ☀️ +72°F" + When call show_weather + The status should be success + The output should include "☀️" + The output should include "+72°F" + End + + It 'handles not found response from API' + GOODMORNING_WEATHER_LOCATION="InvalidCity123" + MOCK_WEATHER_RESPONSE="not found: InvalidCity123" + When call show_weather + The status should be success + The output should include "Weather unavailable for" + The output should include "InvalidCity123" + End + + It 'handles empty response from API' + GOODMORNING_WEATHER_LOCATION="TestCity" + MOCK_WEATHER_RESPONSE="" + When call show_weather + The status should be success + The output should include "Weather unavailable for" + The output should include "TestCity" + End + End + + Context 'URL and parameter validation' + It 'uses location in wttr.in URL construction' + setup_url_check() { + fetch_with_spinner() { + shift # Skip message parameter + # Verify URL contains location + for arg in "$@"; do + if [[ "$arg" == *"wttr.in/New_York"* ]]; then + echo "New York 🌧️ +65°F" + return 0 + fi + done + echo "URL validation failed" >&2 + return 1 + } + } + + setup_url_check + GOODMORNING_WEATHER_LOCATION="New_York" + When call show_weather + The status should be success + The output should include "🌧️" + End + + It 'passes correct parameters to curl command' + setup_param_check() { + fetch_with_spinner() { + shift # Skip message parameter + # Check for expected curl command and flags + local has_curl=false + local has_silent=false + local has_timeout=false + local has_url=false + + for arg in "$@"; do + [[ "$arg" == "curl" ]] && has_curl=true + [[ "$arg" == "-s" ]] && has_silent=true + [[ "$arg" == "--max-time" ]] && has_timeout=true + [[ "$arg" == *"wttr.in/Boston"* ]] && has_url=true + done + + if $has_curl && $has_silent && $has_timeout && $has_url; then + echo "Boston ⛅ +68°F" + return 0 + fi + echo "Missing expected curl parameters" >&2 + return 1 + } + } + + setup_param_check + GOODMORNING_WEATHER_LOCATION="Boston" + When call show_weather + The status should be success + The output should include "⛅" + End End End From 0643f383ec72b7b2e9f78c4698369dee19f9cbb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:59:13 +0000 Subject: [PATCH 18/18] Add validation to basic mock to verify curl command is passed Co-authored-by: bgreg <3711139+bgreg@users.noreply.github.com> --- spec/lib/display_spec.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/spec/lib/display_spec.sh b/spec/lib/display_spec.sh index e02bc02..1b72c67 100644 --- a/spec/lib/display_spec.sh +++ b/spec/lib/display_spec.sh @@ -64,6 +64,11 @@ Describe 'lib/app/display.sh - Display Functions' setup_basic_mock() { fetch_with_spinner() { shift # Skip the message parameter + # Verify we're being called with curl command + if [ "$1" != "curl" ]; then + echo "Expected curl command, got: $1" >&2 + return 1 + fi echo "$MOCK_WEATHER_RESPONSE" } }