feat: add statusline with context usage, model, and current task

- Install statusline.sh to {claudeDir}/hooks/
- Configure statusLine in settings.json automatically
- Skip if statusline already exists (non-interactive)
- Prompt to keep/replace in interactive mode
- Add --force-statusline flag to override

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson
2026-01-16 07:12:11 -06:00
parent 83ecf38028
commit 159925c058
3 changed files with 186 additions and 4 deletions

View File

@@ -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 <path>${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();
}

49
hooks/statusline.sh Normal file
View File

@@ -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<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
# 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

View File

@@ -9,7 +9,8 @@
"bin",
"commands",
"get-shit-done",
"agents"
"agents",
"hooks"
],
"keywords": [
"claude",