fix: Windows hook support via Node.js conversion

- Convert statusline.sh and gsd-check-update.sh to Node.js
- Remove gsd-notify.sh (blocking popups, not true toasts)
- Remove --force-notify and --no-notify flags from installer
- Use `node` command for cross-platform hook execution

Fixes #114

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson
2026-01-16 22:56:26 -06:00
parent 7c60722b71
commit 967734df21
6 changed files with 148 additions and 246 deletions

View File

@@ -55,8 +55,6 @@ 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);
@@ -70,8 +68,6 @@ 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}
@@ -221,10 +217,6 @@ function install(isGlobal) {
const srcFile = path.join(hooksSrc, entry);
const destFile = path.join(hooksDest, entry);
fs.copyFileSync(srcFile, destFile);
// Make shell scripts executable
if (entry.endsWith('.sh')) {
fs.chmodSync(destFile, 0o755);
}
}
console.log(` ${green}✓${reset} Installed hooks`);
}
@@ -233,14 +225,11 @@ function install(isGlobal) {
const settingsPath = path.join(claudeDir, 'settings.json');
const settings = readSettings(settingsPath);
const statuslineCommand = isGlobal
? '$HOME/.claude/hooks/statusline.sh'
: '.claude/hooks/statusline.sh';
? 'node "$HOME/.claude/hooks/statusline.js"'
: 'node .claude/hooks/statusline.js';
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';
? 'node "$HOME/.claude/hooks/gsd-check-update.js"'
: 'node .claude/hooks/gsd-check-update.js';
// Configure SessionStart hook for update checking
if (!settings.hooks) {
@@ -267,13 +256,13 @@ function install(isGlobal) {
console.log(` ${green}✓${reset} Configured update check hook`);
}
return { settingsPath, settings, statuslineCommand, notifyCommand };
return { settingsPath, settings, statuslineCommand };
}
/**
* Apply statusline and notification config, then print completion message
* Apply statusline config, then print completion message
*/
function finishInstall(settingsPath, settings, statuslineCommand, notifyCommand, shouldInstallStatusline, shouldInstallNotify) {
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline) {
if (shouldInstallStatusline) {
settings.statusLine = {
type: 'command',
@@ -282,25 +271,6 @@ function finishInstall(settingsPath, settings, statuslineCommand, notifyCommand,
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);
@@ -365,66 +335,6 @@ function handleStatusline(settings, isInteractive, callback) {
});
}
/**
* 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
*/
@@ -448,12 +358,10 @@ function promptLocation() {
rl.close();
const choice = answer.trim() || '1';
const isGlobal = choice !== '2';
const { settingsPath, settings, statuslineCommand, notifyCommand } = install(isGlobal);
const { settingsPath, settings, statuslineCommand } = install(isGlobal);
// Interactive mode - prompt for optional features
handleStatusline(settings, true, (shouldInstallStatusline) => {
handleNotifications(settings, true, (shouldInstallNotify) => {
finishInstall(settingsPath, settings, statuslineCommand, notifyCommand, shouldInstallStatusline, shouldInstallNotify);
});
finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline);
});
});
}
@@ -466,20 +374,16 @@ if (hasGlobal && hasLocal) {
console.error(` ${yellow}Cannot use --config-dir with --local${reset}`);
process.exit(1);
} else if (hasGlobal) {
const { settingsPath, settings, statuslineCommand, notifyCommand } = install(true);
const { settingsPath, settings, statuslineCommand } = install(true);
// Non-interactive - respect flags
handleStatusline(settings, false, (shouldInstallStatusline) => {
handleNotifications(settings, false, (shouldInstallNotify) => {
finishInstall(settingsPath, settings, statuslineCommand, notifyCommand, shouldInstallStatusline, shouldInstallNotify);
});
finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline);
});
} else if (hasLocal) {
const { settingsPath, settings, statuslineCommand, notifyCommand } = install(false);
const { settingsPath, settings, statuslineCommand } = install(false);
// Non-interactive - respect flags
handleStatusline(settings, false, (shouldInstallStatusline) => {
handleNotifications(settings, false, (shouldInstallNotify) => {
finishInstall(settingsPath, settings, statuslineCommand, notifyCommand, shouldInstallStatusline, shouldInstallNotify);
});
finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline);
});
} else {
promptLocation();

51
hooks/gsd-check-update.js Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env node
// Check for GSD updates in background, write result to cache
// Called by SessionStart hook - runs once per session
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, spawn } = require('child_process');
const homeDir = os.homedir();
const cacheDir = path.join(homeDir, '.claude', 'cache');
const cacheFile = path.join(cacheDir, 'gsd-update-check.json');
const versionFile = path.join(homeDir, '.claude', 'get-shit-done', 'VERSION');
// Ensure cache directory exists
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
// Run check in background (spawn detached process)
const child = spawn(process.execPath, ['-e', `
const fs = require('fs');
const { execSync } = require('child_process');
const cacheFile = ${JSON.stringify(cacheFile)};
const versionFile = ${JSON.stringify(versionFile)};
let installed = '0.0.0';
try {
installed = fs.readFileSync(versionFile, 'utf8').trim();
} catch (e) {}
let latest = null;
try {
latest = execSync('npm view get-shit-done-cc version', { encoding: 'utf8', timeout: 10000 }).trim();
} catch (e) {}
const result = {
update_available: latest && installed !== latest,
installed,
latest: latest || 'unknown',
checked: Math.floor(Date.now() / 1000)
};
fs.writeFileSync(cacheFile, JSON.stringify(result));
`], {
detached: true,
stdio: 'ignore'
});
child.unref();

View File

@@ -1,20 +0,0 @@
#!/bin/bash
# Check for GSD updates in background, write result to cache
# Called by SessionStart hook - runs once per session
CACHE_FILE="$HOME/.claude/cache/gsd-update-check.json"
mkdir -p "$HOME/.claude/cache"
# Run check in background (non-blocking)
(
installed=$(cat "$HOME/.claude/get-shit-done/VERSION" 2>/dev/null || echo "0.0.0")
latest=$(npm view get-shit-done-cc version 2>/dev/null)
if [[ -n "$latest" && "$installed" != "$latest" ]]; then
echo "{\"update_available\":true,\"installed\":\"$installed\",\"latest\":\"$latest\",\"checked\":$(date +%s)}" > "$CACHE_FILE"
else
echo "{\"update_available\":false,\"installed\":\"$installed\",\"latest\":\"${latest:-unknown}\",\"checked\":$(date +%s)}" > "$CACHE_FILE"
fi
) &
exit 0

View File

@@ -1,59 +0,0 @@
#!/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
# Check todo list for current/completed task
message=""
if [[ -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

84
hooks/statusline.js Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env node
// Claude Code Statusline - GSD Edition
// Shows: model | current task | directory | context usage
const fs = require('fs');
const path = require('path');
const os = require('os');
// Read JSON from stdin
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
try {
const data = JSON.parse(input);
const model = data.model?.display_name || 'Claude';
const dir = data.workspace?.current_dir || process.cwd();
const session = data.session_id || '';
const remaining = data.context_window?.remaining_percentage;
// Context window display (shows USED percentage)
let ctx = '';
if (remaining != null) {
const rem = Math.round(remaining);
const used = 100 - rem;
// Build progress bar (10 segments)
const filled = Math.floor(used / 10);
const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
// Color based on usage
if (used < 50) {
ctx = ` \x1b[32m${bar} ${used}%\x1b[0m`;
} else if (used < 65) {
ctx = ` \x1b[33m${bar} ${used}%\x1b[0m`;
} else if (used < 80) {
ctx = ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`;
} else {
ctx = ` \x1b[5;31m💀 ${bar} ${used}%\x1b[0m`;
}
}
// Current task from todos
let task = '';
const homeDir = os.homedir();
const todosDir = path.join(homeDir, '.claude', 'todos');
if (session && fs.existsSync(todosDir)) {
const files = fs.readdirSync(todosDir)
.filter(f => f.startsWith(session) && f.includes('-agent-') && f.endsWith('.json'))
.map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime }))
.sort((a, b) => b.mtime - a.mtime);
if (files.length > 0) {
try {
const todos = JSON.parse(fs.readFileSync(path.join(todosDir, files[0].name), 'utf8'));
const inProgress = todos.find(t => t.status === 'in_progress');
if (inProgress) task = inProgress.activeForm || '';
} catch (e) {}
}
}
// GSD update available?
let gsdUpdate = '';
const cacheFile = path.join(homeDir, '.claude', 'cache', 'gsd-update-check.json');
if (fs.existsSync(cacheFile)) {
try {
const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
if (cache.update_available) {
gsdUpdate = '\x1b[33m⬆ /gsd:update\x1b[0m │ ';
}
} catch (e) {}
}
// Output
const dirname = path.basename(dir);
if (task) {
process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ \x1b[1m${task}\x1b[0m │ \x1b[2m${dirname}\x1b[0m${ctx}`);
} else {
process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ \x1b[2m${dirname}\x1b[0m${ctx}`);
}
} catch (e) {
// Silent fail - don't break statusline on parse errors
}
});

View File

@@ -1,58 +0,0 @@
#!/bin/bash
# Claude Code Statusline - GSD Edition
# Shows: model | current task | directory | context usage
input=$(cat)
model=$(echo "$input" | jq -r '.model.display_name')
dir=$(echo "$input" | jq -r '.workspace.current_dir')
session=$(echo "$input" | jq -r '.session_id')
remaining=$(echo "$input" | jq -r '.context_window.remaining_percentage // empty')
# Context window display (shows USED percentage)
ctx=""
if [ -n "$remaining" ]; then
rem=$(printf "%.0f" "$remaining")
used=$((100 - rem))
# Build progress bar (10 segments) - fills as context is consumed
filled=$((used / 10))
bar=""
for ((i=0; i<filled; i++)); do bar+="█"; done
for ((i=filled; i<10; i++)); do bar+="░"; done
# Color based on usage with blinking skull at 80%+
if [ "$used" -lt 50 ]; then
ctx=$' \033[32m'"$bar $used%"$'\033[0m'
elif [ "$used" -lt 65 ]; then
ctx=$' \033[33m'"$bar $used%"$'\033[0m'
elif [ "$used" -lt 80 ]; then
ctx=$' \033[38;5;208m'"$bar $used%"$'\033[0m'
else
# Blinking red with skull
ctx=$' \033[5;31m💀 '"$bar $used%"$'\033[0m'
fi
fi
# Current task from todos
task=""
todo=$(ls -t "$HOME/.claude/todos/${session}"-agent-*.json 2>/dev/null | head -1)
if [[ -f "$todo" ]]; then
task=$(jq -r '.[] | select(.status=="in_progress") | .activeForm' "$todo" 2>/dev/null | head -1)
fi
# GSD update available?
gsd_update=""
if [[ -f "$HOME/.claude/cache/gsd-update-check.json" ]]; then
update_available=$(jq -r '.update_available' "$HOME/.claude/cache/gsd-update-check.json" 2>/dev/null)
if [[ "$update_available" == "true" ]]; then
gsd_update=$'\033[33m⬆ /gsd:update\033[0m │ '
fi
fi
# Output
dirname=$(basename "$dir")
if [[ -n "$task" ]]; then
printf '%s\033[2m%s\033[0m │ \033[1m%s\033[0m │ \033[2m%s\033[0m%s' "$gsd_update" "$model" "$task" "$dirname" "$ctx"
else
printf '%s\033[2m%s\033[0m │ \033[2m%s\033[0m%s' "$gsd_update" "$model" "$dirname" "$ctx"
fi