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:
122
bin/install.js
122
bin/install.js
@@ -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
51
hooks/gsd-check-update.js
Executable 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();
|
||||
@@ -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
|
||||
@@ -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
84
hooks/statusline.js
Executable 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
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user