diff --git a/bin/install.js b/bin/install.js index e04b10c46..a8d2af9b5 100755 --- a/bin/install.js +++ b/bin/install.js @@ -55,6 +55,8 @@ function parseConfigDirArg() { const explicitConfigDir = parseConfigDirArg(); const hasHelp = args.includes('--help') || args.includes('-h'); const forceStatusline = args.includes('--force-statusline'); +const forceNotify = args.includes('--force-notify'); +const noNotify = args.includes('--no-notify'); console.log(banner); @@ -68,6 +70,8 @@ if (hasHelp) { ${cyan}-c, --config-dir ${reset} Specify custom Claude config directory ${cyan}-h, --help${reset} Show this help message ${cyan}--force-statusline${reset} Replace existing statusline config + ${cyan}--force-notify${reset} Replace existing notification hook + ${cyan}--no-notify${reset} Skip notification hook installation ${yellow}Examples:${reset} ${dim}# Install to default ~/.claude directory${reset} @@ -234,6 +238,9 @@ function install(isGlobal) { const updateCheckCommand = isGlobal ? '$HOME/.claude/hooks/gsd-check-update.sh' : '.claude/hooks/gsd-check-update.sh'; + const notifyCommand = isGlobal + ? '$HOME/.claude/hooks/gsd-notify.sh' + : '.claude/hooks/gsd-notify.sh'; // Configure SessionStart hook for update checking if (!settings.hooks) { @@ -260,13 +267,13 @@ function install(isGlobal) { console.log(` ${green}✓${reset} Configured update check hook`); } - return { settingsPath, settings, statuslineCommand }; + return { settingsPath, settings, statuslineCommand, notifyCommand }; } /** - * Apply statusline config and print completion message + * Apply statusline and notification config, then print completion message */ -function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline) { +function finishInstall(settingsPath, settings, statuslineCommand, notifyCommand, shouldInstallStatusline, shouldInstallNotify) { if (shouldInstallStatusline) { settings.statusLine = { type: 'command', @@ -275,6 +282,25 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS console.log(` ${green}✓${reset} Configured statusline`); } + if (shouldInstallNotify) { + if (!settings.hooks.Stop) { + settings.hooks.Stop = []; + } + // Remove any existing GSD notify hook first + settings.hooks.Stop = settings.hooks.Stop.filter(entry => + !(entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-notify'))) + ); + settings.hooks.Stop.push({ + hooks: [ + { + type: 'command', + command: notifyCommand + } + ] + }); + console.log(` ${green}✓${reset} Configured completion notifications`); + } + // Always write settings (hooks were already configured in install()) writeSettings(settingsPath, settings); @@ -286,7 +312,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS /** * Handle statusline configuration with optional prompt */ -function handleStatusline(settingsPath, settings, statuslineCommand, isInteractive, callback) { +function handleStatusline(settings, isInteractive, callback) { const hasExisting = settings.statusLine != null; // No existing statusline - just install it @@ -339,6 +365,66 @@ function handleStatusline(settingsPath, settings, statuslineCommand, isInteracti }); } +/** + * Handle notification hook configuration with optional prompt + */ +function handleNotifications(settings, isInteractive, callback) { + // Check if --no-notify flag was passed + if (noNotify) { + callback(false); + return; + } + + // Check if GSD notify hook already exists + const hasExisting = settings.hooks?.Stop?.some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-notify')) + ); + + // No existing - just install it + if (!hasExisting) { + callback(true); + return; + } + + // Has existing and --force-notify flag + if (forceNotify) { + callback(true); + return; + } + + // Has existing, non-interactive mode - skip + if (!isInteractive) { + console.log(` ${yellow}⚠${reset} Skipping notifications (already configured)`); + console.log(` Use ${cyan}--force-notify${reset} to replace\n`); + callback(false); + return; + } + + // Has existing, interactive mode - prompt user + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + console.log(` + ${yellow}⚠${reset} Existing notification hook detected + + GSD includes completion notifications that alert you when: + • A phase completes planning or execution + • Claude stops and needs your input + • Works on Mac, Linux, and Windows + + ${cyan}1${reset}) Keep existing + ${cyan}2${reset}) Replace with GSD notifications +`); + + rl.question(` Choice ${dim}[1]${reset}: `, (answer) => { + rl.close(); + const choice = answer.trim() || '1'; + callback(choice === '2'); + }); +} + /** * Prompt for install location */ @@ -362,10 +448,12 @@ function promptLocation() { rl.close(); const choice = answer.trim() || '1'; const isGlobal = choice !== '2'; - const { settingsPath, settings, statuslineCommand } = install(isGlobal); - // Interactive mode - prompt for statusline if needed - handleStatusline(settingsPath, settings, statuslineCommand, true, (shouldInstall) => { - finishInstall(settingsPath, settings, statuslineCommand, shouldInstall); + const { settingsPath, settings, statuslineCommand, notifyCommand } = install(isGlobal); + // Interactive mode - prompt for optional features + handleStatusline(settings, true, (shouldInstallStatusline) => { + handleNotifications(settings, true, (shouldInstallNotify) => { + finishInstall(settingsPath, settings, statuslineCommand, notifyCommand, shouldInstallStatusline, shouldInstallNotify); + }); }); }); } @@ -378,16 +466,20 @@ if (hasGlobal && hasLocal) { console.error(` ${yellow}Cannot use --config-dir with --local${reset}`); process.exit(1); } else if (hasGlobal) { - const { settingsPath, settings, statuslineCommand } = install(true); - // Non-interactive - skip prompt, respect --force-statusline - handleStatusline(settingsPath, settings, statuslineCommand, false, (shouldInstall) => { - finishInstall(settingsPath, settings, statuslineCommand, shouldInstall); + const { settingsPath, settings, statuslineCommand, notifyCommand } = install(true); + // Non-interactive - respect flags + handleStatusline(settings, false, (shouldInstallStatusline) => { + handleNotifications(settings, false, (shouldInstallNotify) => { + finishInstall(settingsPath, settings, statuslineCommand, notifyCommand, shouldInstallStatusline, shouldInstallNotify); + }); }); } else if (hasLocal) { - const { settingsPath, settings, statuslineCommand } = install(false); - // Non-interactive - skip prompt, respect --force-statusline - handleStatusline(settingsPath, settings, statuslineCommand, false, (shouldInstall) => { - finishInstall(settingsPath, settings, statuslineCommand, shouldInstall); + const { settingsPath, settings, statuslineCommand, notifyCommand } = install(false); + // Non-interactive - respect flags + handleStatusline(settings, false, (shouldInstallStatusline) => { + handleNotifications(settings, false, (shouldInstallNotify) => { + finishInstall(settingsPath, settings, statuslineCommand, notifyCommand, shouldInstallStatusline, shouldInstallNotify); + }); }); } else { promptLocation(); diff --git a/hooks/gsd-notify.sh b/hooks/gsd-notify.sh new file mode 100755 index 000000000..8aaf1e661 --- /dev/null +++ b/hooks/gsd-notify.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# GSD Completion Notification Hook +# Cross-platform alert when Claude stops (task complete, needs input, etc.) + +input=$(cat) +session_id=$(echo "$input" | jq -r '.session_id // empty') +cwd=$(echo "$input" | jq -r '.cwd // empty') +transcript_path=$(echo "$input" | jq -r '.transcript_path // empty') + +# Extract project name +project="Claude Code" +if [[ -n "$cwd" ]]; then + project=$(basename "$cwd") +fi + +# Try to get context from GSD state file +message="" +state_file="$cwd/.planning/STATE.md" +if [[ -f "$state_file" ]]; then + phase=$(grep -m1 "^current_phase:" "$state_file" 2>/dev/null | sed 's/^current_phase: *//') + status=$(grep -m1 "^status:" "$state_file" 2>/dev/null | sed 's/^status: *//') + if [[ -n "$phase" && -n "$status" ]]; then + message="Phase $phase: $status" + fi +fi + +# Fallback: check todo list for current/completed task +if [[ -z "$message" && -n "$session_id" ]]; then + todo_file=$(ls -t "$HOME/.claude/todos/${session_id}"*.json 2>/dev/null | head -1) + if [[ -f "$todo_file" ]]; then + # Get most recently completed task, or in-progress task + completed=$(jq -r '[.[] | select(.status=="completed")] | last | .content // empty' "$todo_file" 2>/dev/null) + if [[ -n "$completed" ]]; then + message="Completed: $completed" + else + in_progress=$(jq -r '.[] | select(.status=="in_progress") | .content' "$todo_file" 2>/dev/null | head -1) + if [[ -n "$in_progress" ]]; then + message="Paused: $in_progress" + fi + fi + fi +fi + +# Fallback: generic message +if [[ -z "$message" ]]; then + message="Ready for input" +fi + +# Send notification based on OS +case "$(uname -s)" in + Darwin) + osascript -e "display alert \"GSD: $project\" message \"$message\" as informational" &>/dev/null & + ;; + Linux) + if command -v notify-send &>/dev/null; then + notify-send "GSD: $project" "$message" --urgency=normal + elif command -v zenity &>/dev/null; then + zenity --info --title="GSD: $project" --text="$message" & + fi + ;; + MINGW*|CYGWIN*|MSYS*) + # Windows via PowerShell + if command -v powershell.exe &>/dev/null; then + powershell.exe -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('$message', 'GSD: $project', 'OK', 'Information')" &>/dev/null & + fi + ;; +esac + +exit 0