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:
138
bin/install.js
138
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 <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
49
hooks/statusline.sh
Normal 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
|
||||
@@ -9,7 +9,8 @@
|
||||
"bin",
|
||||
"commands",
|
||||
"get-shit-done",
|
||||
"agents"
|
||||
"agents",
|
||||
"hooks"
|
||||
],
|
||||
"keywords": [
|
||||
"claude",
|
||||
|
||||
Reference in New Issue
Block a user