Files
msd-core/bin/install.js
Lex Christopherson 7a451e6410 feat(statusline): show update indicator when new GSD version available
Adds SessionStart hook that checks npm in background, caches result.
Statusline shows "⬆ /gsd:update │" when update available.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 12:53:45 -06:00

395 lines
13 KiB
JavaScript
Executable File

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const os = require('os');
const readline = require('readline');
// Colors
const cyan = '\x1b[36m';
const green = '\x1b[32m';
const yellow = '\x1b[33m';
const dim = '\x1b[2m';
const reset = '\x1b[0m';
// Get version from package.json
const pkg = require('../package.json');
const banner = `
${cyan} ██████╗ ███████╗██████╗
██╔════╝ ██╔════╝██╔══██╗
██║ ███╗███████╗██║ ██║
██║ ██║╚════██║██║ ██║
╚██████╔╝███████║██████╔╝
╚═════╝ ╚══════╝╚═════╝${reset}
Get Shit Done ${dim}v${pkg.version}${reset}
A meta-prompting, context engineering and spec-driven
development system for Claude Code by TÂCHES.
`;
// Parse args
const args = process.argv.slice(2);
const hasGlobal = args.includes('--global') || args.includes('-g');
const hasLocal = args.includes('--local') || args.includes('-l');
// Parse --config-dir argument
function parseConfigDirArg() {
const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c');
if (configDirIndex !== -1) {
const nextArg = args[configDirIndex + 1];
// Error if --config-dir is provided without a value or next arg is another flag
if (!nextArg || nextArg.startsWith('-')) {
console.error(` ${yellow}--config-dir requires a path argument${reset}`);
process.exit(1);
}
return nextArg;
}
// Also handle --config-dir=value format
const configDirArg = args.find(arg => arg.startsWith('--config-dir=') || arg.startsWith('-c='));
if (configDirArg) {
return configDirArg.split('=')[1];
}
return null;
}
const explicitConfigDir = parseConfigDirArg();
const hasHelp = args.includes('--help') || args.includes('-h');
const forceStatusline = args.includes('--force-statusline');
console.log(banner);
// Show help if requested
if (hasHelp) {
console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]
${yellow}Options:${reset}
${cyan}-g, --global${reset} Install globally (to Claude config directory)
${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}
npx get-shit-done-cc --global
${dim}# Install to custom config directory (for multiple Claude accounts)${reset}
npx get-shit-done-cc --global --config-dir ~/.claude-bc
${dim}# Using environment variable${reset}
CLAUDE_CONFIG_DIR=~/.claude-bc npx get-shit-done-cc --global
${dim}# Install to current project only${reset}
npx get-shit-done-cc --local
${yellow}Notes:${reset}
The --config-dir option is useful when you have multiple Claude Code
configurations (e.g., for different subscriptions). It takes priority
over the CLAUDE_CONFIG_DIR environment variable.
`);
process.exit(0);
}
/**
* Expand ~ to home directory (shell doesn't expand in env vars passed to node)
*/
function expandTilde(filePath) {
if (filePath && filePath.startsWith('~/')) {
return path.join(os.homedir(), filePath.slice(2));
}
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
*/
function copyWithPathReplacement(srcDir, destDir, pathPrefix) {
fs.mkdirSync(destDir, { recursive: true });
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
if (entry.isDirectory()) {
copyWithPathReplacement(srcPath, destPath, pathPrefix);
} else if (entry.name.endsWith('.md')) {
// Replace ~/.claude/ with the appropriate prefix in markdown files
let content = fs.readFileSync(srcPath, 'utf8');
content = content.replace(/~\/\.claude\//g, pathPrefix);
fs.writeFileSync(destPath, content);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Install to the specified directory
*/
function install(isGlobal) {
const src = path.join(__dirname, '..');
// Priority: explicit --config-dir arg > CLAUDE_CONFIG_DIR env var > default ~/.claude
const configDir = expandTilde(explicitConfigDir) || expandTilde(process.env.CLAUDE_CONFIG_DIR);
const defaultGlobalDir = configDir || path.join(os.homedir(), '.claude');
const claudeDir = isGlobal
? defaultGlobalDir
: path.join(process.cwd(), '.claude');
const locationLabel = isGlobal
? claudeDir.replace(os.homedir(), '~')
: claudeDir.replace(process.cwd(), '.');
// Path prefix for file references
// Use actual path when CLAUDE_CONFIG_DIR is set, otherwise use ~ shorthand
const pathPrefix = isGlobal
? (configDir ? `${claudeDir}/` : '~/.claude/')
: './.claude/';
console.log(` Installing to ${cyan}${locationLabel}${reset}\n`);
// Create commands directory
const commandsDir = path.join(claudeDir, 'commands');
fs.mkdirSync(commandsDir, { recursive: true });
// Copy commands/gsd with path replacement
const gsdSrc = path.join(src, 'commands', 'gsd');
const gsdDest = path.join(commandsDir, 'gsd');
copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix);
console.log(` ${green}✓${reset} Installed commands/gsd`);
// Copy get-shit-done skill with path replacement
const skillSrc = path.join(src, 'get-shit-done');
const skillDest = path.join(claudeDir, 'get-shit-done');
copyWithPathReplacement(skillSrc, skillDest, pathPrefix);
console.log(` ${green}✓${reset} Installed get-shit-done`);
// Copy agents to ~/.claude/agents (subagents must be at root level)
const agentsSrc = path.join(src, 'agents');
if (fs.existsSync(agentsSrc)) {
const agentsDest = path.join(claudeDir, 'agents');
copyWithPathReplacement(agentsSrc, agentsDest, pathPrefix);
console.log(` ${green}✓${reset} Installed agents`);
}
// Copy CHANGELOG.md
const changelogSrc = path.join(src, 'CHANGELOG.md');
const changelogDest = path.join(claudeDir, 'get-shit-done', 'CHANGELOG.md');
if (fs.existsSync(changelogSrc)) {
fs.copyFileSync(changelogSrc, changelogDest);
console.log(` ${green}✓${reset} Installed CHANGELOG.md`);
}
// Write VERSION file for whats-new command
const versionDest = path.join(claudeDir, 'get-shit-done', 'VERSION');
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 and hooks 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';
const updateCheckCommand = isGlobal
? '$HOME/.claude/hooks/gsd-check-update.sh'
: '.claude/hooks/gsd-check-update.sh';
// Configure SessionStart hook for update checking
if (!settings.hooks) {
settings.hooks = {};
}
if (!settings.hooks.SessionStart) {
settings.hooks.SessionStart = [];
}
// Check if GSD update hook already exists
const hasGsdUpdateHook = settings.hooks.SessionStart.some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-check-update'))
);
if (!hasGsdUpdateHook) {
settings.hooks.SessionStart.push({
hooks: [
{
type: 'command',
command: updateCheckCommand
}
]
});
console.log(` ${green}✓${reset} Configured update check hook`);
}
return { settingsPath, settings, statuslineCommand };
}
/**
* Apply statusline config and print completion message
*/
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline) {
if (shouldInstallStatusline) {
settings.statusLine = {
type: 'command',
command: statuslineCommand
};
console.log(` ${green}✓${reset} Configured statusline`);
}
// Always write settings (hooks were already configured in install())
writeSettings(settingsPath, settings);
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
*/
function promptLocation() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const configDir = expandTilde(explicitConfigDir) || expandTilde(process.env.CLAUDE_CONFIG_DIR);
const globalPath = configDir || path.join(os.homedir(), '.claude');
const globalLabel = globalPath.replace(os.homedir(), '~');
console.log(` ${yellow}Where would you like to install?${reset}
${cyan}1${reset}) Global ${dim}(${globalLabel})${reset} - available in all projects
${cyan}2${reset}) Local ${dim}(./.claude)${reset} - this project only
`);
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
rl.close();
const choice = answer.trim() || '1';
const isGlobal = choice !== '2';
const { settingsPath, settings, statuslineCommand } = install(isGlobal);
// Interactive mode - prompt for statusline if needed
handleStatusline(settingsPath, settings, statuslineCommand, true, (shouldInstall) => {
finishInstall(settingsPath, settings, statuslineCommand, shouldInstall);
});
});
}
// Main
if (hasGlobal && hasLocal) {
console.error(` ${yellow}Cannot specify both --global and --local${reset}`);
process.exit(1);
} else if (explicitConfigDir && hasLocal) {
console.error(` ${yellow}Cannot use --config-dir with --local${reset}`);
process.exit(1);
} else if (hasGlobal) {
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) {
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();
}