diff --git a/bin/install.js b/bin/install.js index a414b381a..12baca142 100755 --- a/bin/install.js +++ b/bin/install.js @@ -54,6 +54,7 @@ function parseConfigDirArg() { } const explicitConfigDir = parseConfigDirArg(); const hasHelp = args.includes('--help') || args.includes('-h'); +const forceStatusline = args.includes('--force-statusline'); console.log(banner); @@ -66,6 +67,7 @@ if (hasHelp) { ${cyan}-l, --local${reset} Install locally (to ./.claude in current directory) ${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 ${yellow}Examples:${reset} ${dim}# Install to default ~/.claude directory${reset} @@ -98,6 +100,27 @@ function expandTilde(filePath) { return filePath; } +/** + * Read and parse settings.json, returning empty object if doesn't exist + */ +function readSettings(settingsPath) { + if (fs.existsSync(settingsPath)) { + try { + return JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + } catch (e) { + return {}; + } + } + return {}; +} + +/** + * Write settings.json with proper formatting + */ +function writeSettings(settingsPath, settings) { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); +} + /** * Recursively copy directory, replacing paths in .md files */ @@ -184,11 +207,108 @@ function install(isGlobal) { fs.writeFileSync(versionDest, pkg.version); console.log(` ${green}✓${reset} Wrote VERSION (${pkg.version})`); + // Copy hooks + const hooksSrc = path.join(src, 'hooks'); + if (fs.existsSync(hooksSrc)) { + const hooksDest = path.join(claudeDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + const hookEntries = fs.readdirSync(hooksSrc); + for (const entry of hookEntries) { + 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`); + } + + // Configure statusline in settings.json + const settingsPath = path.join(claudeDir, 'settings.json'); + const settings = readSettings(settingsPath); + const statuslineCommand = isGlobal + ? '$HOME/.claude/hooks/statusline.sh' + : '.claude/hooks/statusline.sh'; + + return { settingsPath, settings, statuslineCommand }; +} + +/** + * Apply statusline config and print completion message + */ +function finishInstall(settingsPath, settings, statuslineCommand, shouldInstall) { + if (shouldInstall) { + settings.statusLine = { + type: 'command', + command: statuslineCommand + }; + writeSettings(settingsPath, settings); + console.log(` ${green}✓${reset} Configured statusline`); + } + console.log(` ${green}Done!${reset} Launch Claude Code and run ${cyan}/gsd:help${reset}. `); } +/** + * Handle statusline configuration with optional prompt + */ +function handleStatusline(settingsPath, settings, statuslineCommand, isInteractive, callback) { + const hasExisting = settings.statusLine != null; + + // No existing statusline - just install it + if (!hasExisting) { + callback(true); + return; + } + + // Has existing and --force-statusline flag + if (forceStatusline) { + callback(true); + return; + } + + // Has existing, non-interactive mode - skip + if (!isInteractive) { + console.log(` ${yellow}⚠${reset} Skipping statusline (already configured)`); + console.log(` Use ${cyan}--force-statusline${reset} to replace\n`); + callback(false); + return; + } + + // Has existing, interactive mode - prompt user + const existingCmd = settings.statusLine.command || settings.statusLine.url || '(custom)'; + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + console.log(` + ${yellow}⚠${reset} Existing statusline detected + + Your current statusline: + ${dim}command: ${existingCmd}${reset} + + GSD includes a statusline showing: + • Model name + • Current task (from todo list) + • Context window usage (color-coded) + + ${cyan}1${reset}) Keep existing + ${cyan}2${reset}) Replace with GSD statusline +`); + + rl.question(` Choice ${dim}[1]${reset}: `, (answer) => { + rl.close(); + const choice = answer.trim() || '1'; + callback(choice === '2'); + }); +} + /** * Prompt for install location */ @@ -212,7 +332,11 @@ function promptLocation() { rl.close(); const choice = answer.trim() || '1'; const isGlobal = choice !== '2'; - install(isGlobal); + const { settingsPath, settings, statuslineCommand } = install(isGlobal); + // Interactive mode - prompt for statusline if needed + handleStatusline(settingsPath, settings, statuslineCommand, true, (shouldInstall) => { + finishInstall(settingsPath, settings, statuslineCommand, shouldInstall); + }); }); } @@ -224,9 +348,17 @@ if (hasGlobal && hasLocal) { console.error(` ${yellow}Cannot use --config-dir with --local${reset}`); process.exit(1); } else if (hasGlobal) { - install(true); + const { settingsPath, settings, statuslineCommand } = install(true); + // Non-interactive - skip prompt, respect --force-statusline + handleStatusline(settingsPath, settings, statuslineCommand, false, (shouldInstall) => { + finishInstall(settingsPath, settings, statuslineCommand, shouldInstall); + }); } else if (hasLocal) { - install(false); + const { settingsPath, settings, statuslineCommand } = install(false); + // Non-interactive - skip prompt, respect --force-statusline + handleStatusline(settingsPath, settings, statuslineCommand, false, (shouldInstall) => { + finishInstall(settingsPath, settings, statuslineCommand, shouldInstall); + }); } else { promptLocation(); } diff --git a/hooks/statusline.sh b/hooks/statusline.sh new file mode 100644 index 000000000..fad92cac7 --- /dev/null +++ b/hooks/statusline.sh @@ -0,0 +1,49 @@ +#!/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/dev/null | head -1) +if [[ -f "$todo" ]]; then + task=$(jq -r '.[] | select(.status=="in_progress") | .activeForm' "$todo" 2>/dev/null | head -1) +fi + +# Output +dirname=$(basename "$dir") +if [[ -n "$task" ]]; then + printf '\033[2m%s\033[0m │ \033[1m%s\033[0m │ \033[2m%s\033[0m%s' "$model" "$task" "$dirname" "$ctx" +else + printf '\033[2m%s\033[0m │ \033[2m%s\033[0m%s' "$model" "$dirname" "$ctx" +fi diff --git a/package.json b/package.json index b9fd79ca4..8e4c5e292 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "bin", "commands", "get-shit-done", - "agents" + "agents", + "hooks" ], "keywords": [ "claude",