feat(hooks): add cross-platform completion notification hook
Alerts user when Claude stops (task complete, needs input, etc.) with context about what completed. Works on Mac (osascript alert), Linux (notify-send/zenity), and Windows (PowerShell MessageBox). Follows same optional install pattern as statusline: - Prompts in interactive mode if existing hook detected - --force-notify to replace existing - --no-notify to skip installation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
124
bin/install.js
124
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 <path>${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();
|
||||
|
||||
69
hooks/gsd-notify.sh
Executable file
69
hooks/gsd-notify.sh
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user