Files
msd-core/bin/install.js
Lex Christopherson bdbcbc4787 feat: show Discord invite link in install completion message
Helps new users discover the community right after installation.

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

1293 lines
43 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');
// Parse args
const args = process.argv.slice(2);
const hasGlobal = args.includes('--global') || args.includes('-g');
const hasLocal = args.includes('--local') || args.includes('-l');
const hasOpencode = args.includes('--opencode');
const hasClaude = args.includes('--claude');
const hasBoth = args.includes('--both');
const hasUninstall = args.includes('--uninstall') || args.includes('-u');
// Runtime selection - can be set by flags or interactive prompt
let selectedRuntimes = [];
if (hasBoth) {
selectedRuntimes = ['claude', 'opencode'];
} else if (hasOpencode) {
selectedRuntimes = ['opencode'];
} else if (hasClaude) {
selectedRuntimes = ['claude'];
}
// Helper to get directory name for a runtime (used for local/project installs)
function getDirName(runtime) {
return runtime === 'opencode' ? '.opencode' : '.claude';
}
/**
* Get the global config directory for OpenCode
* OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
* Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
*/
function getOpencodeGlobalDir() {
// 1. Explicit OPENCODE_CONFIG_DIR env var
if (process.env.OPENCODE_CONFIG_DIR) {
return expandTilde(process.env.OPENCODE_CONFIG_DIR);
}
// 2. OPENCODE_CONFIG env var (use its directory)
if (process.env.OPENCODE_CONFIG) {
return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
}
// 3. XDG_CONFIG_HOME/opencode
if (process.env.XDG_CONFIG_HOME) {
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
}
// 4. Default: ~/.config/opencode (XDG default)
return path.join(os.homedir(), '.config', 'opencode');
}
/**
* Get the global config directory for a runtime
* @param {string} runtime - 'claude' or 'opencode'
* @param {string|null} explicitDir - Explicit directory from --config-dir flag
*/
function getGlobalDir(runtime, explicitDir = null) {
if (runtime === 'opencode') {
// For OpenCode, --config-dir overrides env vars
if (explicitDir) {
return expandTilde(explicitDir);
}
return getOpencodeGlobalDir();
}
// Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CLAUDE_CONFIG_DIR) {
return expandTilde(process.env.CLAUDE_CONFIG_DIR);
}
return path.join(os.homedir(), '.claude');
}
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 (and opencode) by TÂCHES.
`;
// 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) {
const value = configDirArg.split('=')[1];
if (!value) {
console.error(` ${yellow}--config-dir requires a non-empty path${reset}`);
process.exit(1);
}
return value;
}
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 config directory)
${cyan}-l, --local${reset} Install locally (to current directory)
${cyan}--claude${reset} Install for Claude Code only
${cyan}--opencode${reset} Install for OpenCode only
${cyan}--both${reset} Install for both Claude Code and OpenCode
${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)
${cyan}-c, --config-dir <path>${reset} Specify custom config directory
${cyan}-h, --help${reset} Show this help message
${cyan}--force-statusline${reset} Replace existing statusline config
${yellow}Examples:${reset}
${dim}# Interactive install (prompts for runtime and location)${reset}
npx get-shit-done-cc
${dim}# Install for Claude Code globally${reset}
npx get-shit-done-cc --claude --global
${dim}# Install for OpenCode globally${reset}
npx get-shit-done-cc --opencode --global
${dim}# Install for both runtimes globally${reset}
npx get-shit-done-cc --both --global
${dim}# Install to custom config directory${reset}
npx get-shit-done-cc --claude --global --config-dir ~/.claude-bc
${dim}# Install to current project only${reset}
npx get-shit-done-cc --claude --local
${dim}# Uninstall GSD from Claude Code globally${reset}
npx get-shit-done-cc --claude --global --uninstall
${dim}# Uninstall GSD from current project${reset}
npx get-shit-done-cc --claude --local --uninstall
${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;
}
/**
* Build a hook command path using forward slashes for cross-platform compatibility.
* On Windows, $HOME is not expanded by cmd.exe/PowerShell, so we use the actual path.
*/
function buildHookCommand(claudeDir, hookName) {
// Use forward slashes for Node.js compatibility on all platforms
const hooksPath = claudeDir.replace(/\\/g, '/') + '/hooks/' + hookName;
return `node "${hooksPath}"`;
}
/**
* Read and parse settings.json, returning empty object if it 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');
}
/**
* Convert Claude Code frontmatter to opencode format
* - Converts 'allowed-tools:' array to 'permission:' object
* @param {string} content - Markdown file content with YAML frontmatter
* @returns {string} - Content with converted frontmatter
*/
// Color name to hex mapping for opencode compatibility
const colorNameToHex = {
cyan: '#00FFFF',
red: '#FF0000',
green: '#00FF00',
blue: '#0000FF',
yellow: '#FFFF00',
magenta: '#FF00FF',
orange: '#FFA500',
purple: '#800080',
pink: '#FFC0CB',
white: '#FFFFFF',
black: '#000000',
gray: '#808080',
grey: '#808080',
};
// Tool name mapping from Claude Code to OpenCode
// OpenCode uses lowercase tool names; special mappings for renamed tools
const claudeToOpencodeTools = {
AskUserQuestion: 'question',
SlashCommand: 'skill',
TodoWrite: 'todowrite',
WebFetch: 'webfetch',
WebSearch: 'websearch', // Plugin/MCP - keep for compatibility
};
/**
* Convert a Claude Code tool name to OpenCode format
* - Applies special mappings (AskUserQuestion -> question, etc.)
* - Converts to lowercase (except MCP tools which keep their format)
*/
function convertToolName(claudeTool) {
// Check for special mapping first
if (claudeToOpencodeTools[claudeTool]) {
return claudeToOpencodeTools[claudeTool];
}
// MCP tools (mcp__*) keep their format
if (claudeTool.startsWith('mcp__')) {
return claudeTool;
}
// Default: convert to lowercase
return claudeTool.toLowerCase();
}
function convertClaudeToOpencodeFrontmatter(content) {
// Replace tool name references in content (applies to all files)
let convertedContent = content;
convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill');
convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite');
// Replace /gsd:command with /gsd-command for opencode (flat command structure)
convertedContent = convertedContent.replace(/\/gsd:/g, '/gsd-');
// Replace ~/.claude with ~/.config/opencode (OpenCode's correct config location)
convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/opencode');
// Check if content has frontmatter
if (!convertedContent.startsWith('---')) {
return convertedContent;
}
// Find the end of frontmatter
const endIndex = convertedContent.indexOf('---', 3);
if (endIndex === -1) {
return convertedContent;
}
const frontmatter = convertedContent.substring(3, endIndex).trim();
const body = convertedContent.substring(endIndex + 3);
// Parse frontmatter line by line (simple YAML parsing)
const lines = frontmatter.split('\n');
const newLines = [];
let inAllowedTools = false;
const allowedTools = [];
for (const line of lines) {
const trimmed = line.trim();
// Detect start of allowed-tools array
if (trimmed.startsWith('allowed-tools:')) {
inAllowedTools = true;
continue;
}
// Detect inline tools: field (comma-separated string)
if (trimmed.startsWith('tools:')) {
const toolsValue = trimmed.substring(6).trim();
if (toolsValue) {
// Parse comma-separated tools
const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t);
allowedTools.push(...tools);
}
continue;
}
// Remove name: field - opencode uses filename for command name
if (trimmed.startsWith('name:')) {
continue;
}
// Convert color names to hex for opencode
if (trimmed.startsWith('color:')) {
const colorValue = trimmed.substring(6).trim().toLowerCase();
const hexColor = colorNameToHex[colorValue];
if (hexColor) {
newLines.push(`color: "${hexColor}"`);
} else if (colorValue.startsWith('#')) {
// Already hex, keep as is
newLines.push(line);
}
// Skip unknown color names
continue;
}
// Collect allowed-tools items
if (inAllowedTools) {
if (trimmed.startsWith('- ')) {
allowedTools.push(trimmed.substring(2).trim());
continue;
} else if (trimmed && !trimmed.startsWith('-')) {
// End of array, new field started
inAllowedTools = false;
}
}
// Keep other fields (including name: which opencode ignores)
if (!inAllowedTools) {
newLines.push(line);
}
}
// Add tools object if we had allowed-tools or tools
if (allowedTools.length > 0) {
newLines.push('tools:');
for (const tool of allowedTools) {
newLines.push(` ${convertToolName(tool)}: true`);
}
}
// Rebuild frontmatter (body already has tool names converted)
const newFrontmatter = newLines.join('\n').trim();
return `---\n${newFrontmatter}\n---${body}`;
}
/**
* Copy commands to a flat structure for OpenCode
* OpenCode expects: command/gsd-help.md (invoked as /gsd-help)
* Source structure: commands/gsd/help.md
*
* @param {string} srcDir - Source directory (e.g., commands/gsd/)
* @param {string} destDir - Destination directory (e.g., command/)
* @param {string} prefix - Prefix for filenames (e.g., 'gsd')
* @param {string} pathPrefix - Path prefix for file references
* @param {string} runtime - Target runtime ('claude' or 'opencode')
*/
function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
if (!fs.existsSync(srcDir)) {
return;
}
// Remove old gsd-*.md files before copying new ones
if (fs.existsSync(destDir)) {
for (const file of fs.readdirSync(destDir)) {
if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) {
fs.unlinkSync(path.join(destDir, file));
}
}
} else {
fs.mkdirSync(destDir, { recursive: true });
}
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
if (entry.isDirectory()) {
// Recurse into subdirectories, adding to prefix
// e.g., commands/gsd/debug/start.md -> command/gsd-debug-start.md
copyFlattenedCommands(srcPath, destDir, `${prefix}-${entry.name}`, pathPrefix, runtime);
} else if (entry.name.endsWith('.md')) {
// Flatten: help.md -> gsd-help.md
const baseName = entry.name.replace('.md', '');
const destName = `${prefix}-${baseName}.md`;
const destPath = path.join(destDir, destName);
// Read, transform, and write
let content = fs.readFileSync(srcPath, 'utf8');
// Replace path references
const claudeDirRegex = /~\/\.claude\//g;
const opencodeDirRegex = /~\/\.opencode\//g;
content = content.replace(claudeDirRegex, pathPrefix);
content = content.replace(opencodeDirRegex, pathPrefix);
// Convert frontmatter for opencode compatibility
content = convertClaudeToOpencodeFrontmatter(content);
fs.writeFileSync(destPath, content);
}
}
}
/**
* Recursively copy directory, replacing paths in .md files
* Deletes existing destDir first to remove orphaned files from previous versions
* @param {string} srcDir - Source directory
* @param {string} destDir - Destination directory
* @param {string} pathPrefix - Path prefix for file references
* @param {string} runtime - Target runtime ('claude' or 'opencode')
*/
function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) {
const isOpencode = runtime === 'opencode';
const dirName = getDirName(runtime);
// Clean install: remove existing destination to prevent orphaned files
if (fs.existsSync(destDir)) {
fs.rmSync(destDir, { recursive: true });
}
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, runtime);
} else if (entry.name.endsWith('.md')) {
// Replace ~/.claude/ with the appropriate prefix in Markdown files
let content = fs.readFileSync(srcPath, 'utf8');
const claudeDirRegex = new RegExp(`~/${dirName.replace('.', '\\.')}/`, 'g');
content = content.replace(claudeDirRegex, pathPrefix);
// Convert frontmatter for opencode compatibility
if (isOpencode) {
content = convertClaudeToOpencodeFrontmatter(content);
}
fs.writeFileSync(destPath, content);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Clean up orphaned files from previous GSD versions
*/
function cleanupOrphanedFiles(claudeDir) {
const orphanedFiles = [
'hooks/gsd-notify.sh', // Removed in v1.6.x
'hooks/statusline.js', // Renamed to gsd-statusline.js in v1.9.0
];
for (const relPath of orphanedFiles) {
const fullPath = path.join(claudeDir, relPath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
console.log(` ${green}✓${reset} Removed orphaned ${relPath}`);
}
}
}
/**
* Clean up orphaned hook registrations from settings.json
*/
function cleanupOrphanedHooks(settings) {
const orphanedHookPatterns = [
'gsd-notify.sh', // Removed in v1.6.x
'hooks/statusline.js', // Renamed to gsd-statusline.js in v1.9.0
'gsd-intel-index.js', // Removed in v1.9.2
'gsd-intel-session.js', // Removed in v1.9.2
'gsd-intel-prune.js', // Removed in v1.9.2
];
let cleaned = false;
// Check all hook event types (Stop, SessionStart, etc.)
if (settings.hooks) {
for (const eventType of Object.keys(settings.hooks)) {
const hookEntries = settings.hooks[eventType];
if (Array.isArray(hookEntries)) {
// Filter out entries that contain orphaned hooks
const filtered = hookEntries.filter(entry => {
if (entry.hooks && Array.isArray(entry.hooks)) {
// Check if any hook in this entry matches orphaned patterns
const hasOrphaned = entry.hooks.some(h =>
h.command && orphanedHookPatterns.some(pattern => h.command.includes(pattern))
);
if (hasOrphaned) {
cleaned = true;
return false; // Remove this entry
}
}
return true; // Keep this entry
});
settings.hooks[eventType] = filtered;
}
}
}
if (cleaned) {
console.log(` ${green}✓${reset} Removed orphaned hook registrations`);
}
return settings;
}
/**
* Uninstall GSD from the specified directory for a specific runtime
* Removes only GSD-specific files/directories, preserves user content
* @param {boolean} isGlobal - Whether to uninstall from global or local
* @param {string} runtime - Target runtime ('claude' or 'opencode')
*/
function uninstall(isGlobal, runtime = 'claude') {
const isOpencode = runtime === 'opencode';
const dirName = getDirName(runtime);
// Get the target directory based on runtime and install type
const targetDir = isGlobal
? getGlobalDir(runtime, explicitConfigDir)
: path.join(process.cwd(), dirName);
const locationLabel = isGlobal
? targetDir.replace(os.homedir(), '~')
: targetDir.replace(process.cwd(), '.');
const runtimeLabel = isOpencode ? 'OpenCode' : 'Claude Code';
console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
// Check if target directory exists
if (!fs.existsSync(targetDir)) {
console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
console.log(` Nothing to uninstall.\n`);
return;
}
let removedCount = 0;
// 1. Remove GSD commands directory
if (isOpencode) {
// OpenCode: remove command/gsd-*.md files
const commandDir = path.join(targetDir, 'command');
if (fs.existsSync(commandDir)) {
const files = fs.readdirSync(commandDir);
for (const file of files) {
if (file.startsWith('gsd-') && file.endsWith('.md')) {
fs.unlinkSync(path.join(commandDir, file));
removedCount++;
}
}
console.log(` ${green}✓${reset} Removed GSD commands from command/`);
}
} else {
// Claude Code: remove commands/gsd/ directory
const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
if (fs.existsSync(gsdCommandsDir)) {
fs.rmSync(gsdCommandsDir, { recursive: true });
removedCount++;
console.log(` ${green}✓${reset} Removed commands/gsd/`);
}
}
// 2. Remove get-shit-done directory
const gsdDir = path.join(targetDir, 'get-shit-done');
if (fs.existsSync(gsdDir)) {
fs.rmSync(gsdDir, { recursive: true });
removedCount++;
console.log(` ${green}✓${reset} Removed get-shit-done/`);
}
// 3. Remove GSD agents (gsd-*.md files only)
const agentsDir = path.join(targetDir, 'agents');
if (fs.existsSync(agentsDir)) {
const files = fs.readdirSync(agentsDir);
let agentCount = 0;
for (const file of files) {
if (file.startsWith('gsd-') && file.endsWith('.md')) {
fs.unlinkSync(path.join(agentsDir, file));
agentCount++;
}
}
if (agentCount > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${agentCount} GSD agents`);
}
}
// 4. Remove GSD hooks
const hooksDir = path.join(targetDir, 'hooks');
if (fs.existsSync(hooksDir)) {
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh'];
let hookCount = 0;
for (const hook of gsdHooks) {
const hookPath = path.join(hooksDir, hook);
if (fs.existsSync(hookPath)) {
fs.unlinkSync(hookPath);
hookCount++;
}
}
if (hookCount > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${hookCount} GSD hooks`);
}
}
// 5. Clean up settings.json (remove GSD hooks and statusline)
const settingsPath = path.join(targetDir, 'settings.json');
if (fs.existsSync(settingsPath)) {
let settings = readSettings(settingsPath);
let settingsModified = false;
// Remove GSD statusline if it references our hook
if (settings.statusLine && settings.statusLine.command &&
settings.statusLine.command.includes('gsd-statusline')) {
delete settings.statusLine;
settingsModified = true;
console.log(` ${green}✓${reset} Removed GSD statusline from settings`);
}
// Remove GSD hooks from SessionStart
if (settings.hooks && settings.hooks.SessionStart) {
const before = settings.hooks.SessionStart.length;
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry => {
if (entry.hooks && Array.isArray(entry.hooks)) {
// Filter out GSD hooks
const hasGsdHook = entry.hooks.some(h =>
h.command && (h.command.includes('gsd-check-update') || h.command.includes('gsd-statusline'))
);
return !hasGsdHook;
}
return true;
});
if (settings.hooks.SessionStart.length < before) {
settingsModified = true;
console.log(` ${green}✓${reset} Removed GSD hooks from settings`);
}
// Clean up empty array
if (settings.hooks.SessionStart.length === 0) {
delete settings.hooks.SessionStart;
}
// Clean up empty hooks object
if (Object.keys(settings.hooks).length === 0) {
delete settings.hooks;
}
}
if (settingsModified) {
writeSettings(settingsPath, settings);
removedCount++;
}
}
// 6. For OpenCode, clean up permissions from opencode.json
if (isOpencode) {
const opencodeConfigDir = getOpencodeGlobalDir();
const configPath = path.join(opencodeConfigDir, 'opencode.json');
if (fs.existsSync(configPath)) {
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
let modified = false;
// Remove GSD permission entries
if (config.permission) {
for (const permType of ['read', 'external_directory']) {
if (config.permission[permType]) {
const keys = Object.keys(config.permission[permType]);
for (const key of keys) {
if (key.includes('get-shit-done')) {
delete config.permission[permType][key];
modified = true;
}
}
// Clean up empty objects
if (Object.keys(config.permission[permType]).length === 0) {
delete config.permission[permType];
}
}
}
if (Object.keys(config.permission).length === 0) {
delete config.permission;
}
}
if (modified) {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
removedCount++;
console.log(` ${green}✓${reset} Removed GSD permissions from opencode.json`);
}
} catch (e) {
// Ignore JSON parse errors
}
}
}
if (removedCount === 0) {
console.log(` ${yellow}⚠${reset} No GSD files found to remove.`);
}
console.log(`
${green}Done!${reset} GSD has been uninstalled from ${runtimeLabel}.
Your other files and settings have been preserved.
`);
}
/**
* Configure OpenCode permissions to allow reading GSD reference docs
* This prevents permission prompts when GSD accesses the get-shit-done directory
*/
function configureOpencodePermissions() {
// OpenCode config file is at ~/.config/opencode/opencode.json
const opencodeConfigDir = getOpencodeGlobalDir();
const configPath = path.join(opencodeConfigDir, 'opencode.json');
// Ensure config directory exists
fs.mkdirSync(opencodeConfigDir, { recursive: true });
// Read existing config or create empty object
let config = {};
if (fs.existsSync(configPath)) {
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (e) {
// Invalid JSON - start fresh but warn user
console.log(` ${yellow}⚠${reset} opencode.json had invalid JSON, recreating`);
}
}
// Ensure permission structure exists
if (!config.permission) {
config.permission = {};
}
// Build the GSD path using the actual config directory
// Use ~ shorthand if it's in the default location, otherwise use full path
const defaultConfigDir = path.join(os.homedir(), '.config', 'opencode');
const gsdPath = opencodeConfigDir === defaultConfigDir
? '~/.config/opencode/get-shit-done/*'
: `${opencodeConfigDir}/get-shit-done/*`;
let modified = false;
// Configure read permission
if (!config.permission.read || typeof config.permission.read !== 'object') {
config.permission.read = {};
}
if (config.permission.read[gsdPath] !== 'allow') {
config.permission.read[gsdPath] = 'allow';
modified = true;
}
// Configure external_directory permission (the safety guard for paths outside project)
if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
config.permission.external_directory = {};
}
if (config.permission.external_directory[gsdPath] !== 'allow') {
config.permission.external_directory[gsdPath] = 'allow';
modified = true;
}
if (!modified) {
return; // Already configured
}
// Write config back
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
console.log(` ${green}✓${reset} Configured read permission for GSD docs`);
}
/**
* Verify a directory exists and contains files
*/
function verifyInstalled(dirPath, description) {
if (!fs.existsSync(dirPath)) {
console.error(` ${yellow}✗${reset} Failed to install ${description}: directory not created`);
return false;
}
try {
const entries = fs.readdirSync(dirPath);
if (entries.length === 0) {
console.error(` ${yellow}✗${reset} Failed to install ${description}: directory is empty`);
return false;
}
} catch (e) {
console.error(` ${yellow}✗${reset} Failed to install ${description}: ${e.message}`);
return false;
}
return true;
}
/**
* Verify a file exists
*/
function verifyFileInstalled(filePath, description) {
if (!fs.existsSync(filePath)) {
console.error(` ${yellow}✗${reset} Failed to install ${description}: file not created`);
return false;
}
return true;
}
/**
* Install to the specified directory for a specific runtime
* @param {boolean} isGlobal - Whether to install globally or locally
* @param {string} runtime - Target runtime ('claude' or 'opencode')
*/
function install(isGlobal, runtime = 'claude') {
const isOpencode = runtime === 'opencode';
const dirName = getDirName(runtime); // .opencode or .claude (for local installs)
const src = path.join(__dirname, '..');
// Get the target directory based on runtime and install type
const targetDir = isGlobal
? getGlobalDir(runtime, explicitConfigDir)
: path.join(process.cwd(), dirName);
const locationLabel = isGlobal
? targetDir.replace(os.homedir(), '~')
: targetDir.replace(process.cwd(), '.');
// Path prefix for file references in markdown content
// For global installs: use full path (necessary when config dir is customized)
// For local installs: use relative ./.opencode/ or ./.claude/
const pathPrefix = isGlobal
? `${targetDir}/`
: `./${dirName}/`;
const runtimeLabel = isOpencode ? 'OpenCode' : 'Claude Code';
console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
// Track installation failures
const failures = [];
// Clean up orphaned files from previous versions
cleanupOrphanedFiles(targetDir);
// OpenCode uses 'command/' (singular) with flat structure: command/gsd-help.md
// Claude Code uses 'commands/' (plural) with nested structure: commands/gsd/help.md
if (isOpencode) {
// OpenCode: flat structure in command/ directory
const commandDir = path.join(targetDir, 'command');
fs.mkdirSync(commandDir, { recursive: true });
// Copy commands/gsd/*.md as command/gsd-*.md (flatten structure)
const gsdSrc = path.join(src, 'commands', 'gsd');
copyFlattenedCommands(gsdSrc, commandDir, 'gsd', pathPrefix, runtime);
if (verifyInstalled(commandDir, 'command/gsd-*')) {
const count = fs.readdirSync(commandDir).filter(f => f.startsWith('gsd-')).length;
console.log(` ${green}✓${reset} Installed ${count} commands to command/`);
} else {
failures.push('command/gsd-*');
}
} else {
// Claude Code: nested structure in commands/ directory
const commandsDir = path.join(targetDir, 'commands');
fs.mkdirSync(commandsDir, { recursive: true });
const gsdSrc = path.join(src, 'commands', 'gsd');
const gsdDest = path.join(commandsDir, 'gsd');
copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime);
if (verifyInstalled(gsdDest, 'commands/gsd')) {
console.log(` ${green}✓${reset} Installed commands/gsd`);
} else {
failures.push('commands/gsd');
}
}
// Copy get-shit-done skill with path replacement
const skillSrc = path.join(src, 'get-shit-done');
const skillDest = path.join(targetDir, 'get-shit-done');
copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime);
if (verifyInstalled(skillDest, 'get-shit-done')) {
console.log(` ${green}✓${reset} Installed get-shit-done`);
} else {
failures.push('get-shit-done');
}
// Copy agents to agents directory (subagents must be at root level)
// Only delete gsd-*.md files to preserve user's custom agents
const agentsSrc = path.join(src, 'agents');
if (fs.existsSync(agentsSrc)) {
const agentsDest = path.join(targetDir, 'agents');
fs.mkdirSync(agentsDest, { recursive: true });
// Remove old GSD agents (gsd-*.md) before copying new ones
if (fs.existsSync(agentsDest)) {
for (const file of fs.readdirSync(agentsDest)) {
if (file.startsWith('gsd-') && file.endsWith('.md')) {
fs.unlinkSync(path.join(agentsDest, file));
}
}
}
// Copy new agents (don't use copyWithPathReplacement which would wipe the folder)
const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true });
for (const entry of agentEntries) {
if (entry.isFile() && entry.name.endsWith('.md')) {
let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
const dirRegex = new RegExp(`~/${dirName.replace('.', '\\.')}/`, 'g');
content = content.replace(dirRegex, pathPrefix);
// Convert frontmatter for opencode compatibility
if (isOpencode) {
content = convertClaudeToOpencodeFrontmatter(content);
}
fs.writeFileSync(path.join(agentsDest, entry.name), content);
}
}
if (verifyInstalled(agentsDest, 'agents')) {
console.log(` ${green}✓${reset} Installed agents`);
} else {
failures.push('agents');
}
}
// Copy CHANGELOG.md
const changelogSrc = path.join(src, 'CHANGELOG.md');
const changelogDest = path.join(targetDir, 'get-shit-done', 'CHANGELOG.md');
if (fs.existsSync(changelogSrc)) {
fs.copyFileSync(changelogSrc, changelogDest);
if (verifyFileInstalled(changelogDest, 'CHANGELOG.md')) {
console.log(` ${green}✓${reset} Installed CHANGELOG.md`);
} else {
failures.push('CHANGELOG.md');
}
}
// Write VERSION file for whats-new command
const versionDest = path.join(targetDir, 'get-shit-done', 'VERSION');
fs.writeFileSync(versionDest, pkg.version);
if (verifyFileInstalled(versionDest, 'VERSION')) {
console.log(` ${green}✓${reset} Wrote VERSION (${pkg.version})`);
} else {
failures.push('VERSION');
}
// Copy hooks from dist/ (bundled with dependencies)
const hooksSrc = path.join(src, 'hooks', 'dist');
if (fs.existsSync(hooksSrc)) {
const hooksDest = path.join(targetDir, 'hooks');
fs.mkdirSync(hooksDest, { recursive: true });
const hookEntries = fs.readdirSync(hooksSrc);
for (const entry of hookEntries) {
const srcFile = path.join(hooksSrc, entry);
// Only copy files, not directories
if (fs.statSync(srcFile).isFile()) {
const destFile = path.join(hooksDest, entry);
fs.copyFileSync(srcFile, destFile);
}
}
if (verifyInstalled(hooksDest, 'hooks')) {
console.log(` ${green}✓${reset} Installed hooks (bundled)`);
} else {
failures.push('hooks');
}
}
// If critical components failed, exit with error
if (failures.length > 0) {
console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
console.error(` Try running directly: node ~/.npm/_npx/*/node_modules/get-shit-done-cc/bin/install.js --global\n`);
process.exit(1);
}
// Configure statusline and hooks in settings.json
const settingsPath = path.join(targetDir, 'settings.json');
const settings = cleanupOrphanedHooks(readSettings(settingsPath));
const statuslineCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-statusline.js')
: 'node ' + dirName + '/hooks/gsd-statusline.js';
const updateCheckCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-check-update.js')
: 'node ' + dirName + '/hooks/gsd-check-update.js';
// Configure SessionStart hook for update checking (skip for opencode - different hook system)
if (!isOpencode) {
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, runtime };
}
/**
* Apply statusline config, then print completion message
* @param {string} settingsPath - Path to settings.json
* @param {object} settings - Settings object
* @param {string} statuslineCommand - Statusline command
* @param {boolean} shouldInstallStatusline - Whether to install statusline
* @param {string} runtime - Target runtime ('claude' or 'opencode')
*/
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude') {
const isOpencode = runtime === 'opencode';
if (shouldInstallStatusline && !isOpencode) {
settings.statusLine = {
type: 'command',
command: statuslineCommand
};
console.log(` ${green}✓${reset} Configured statusline`);
}
// Always write settings (hooks were already configured in install())
writeSettings(settingsPath, settings);
// Configure OpenCode permissions if needed
if (isOpencode) {
configureOpencodePermissions();
}
const program = isOpencode ? 'OpenCode' : 'Claude Code';
const command = isOpencode ? '/gsd-help' : '/gsd:help';
console.log(`
${green}Done!${reset} Launch ${program} and run ${cyan}${command}${reset}.
${cyan}Join the community:${reset} https://discord.gg/5JJgD5svVS
`);
}
/**
* Handle statusline configuration with optional prompt
*/
function handleStatusline(settings, 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 runtime selection (Claude Code / OpenCode / Both)
* @param {function} callback - Called with array of selected runtimes
*/
function promptRuntime(callback) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
let answered = false;
rl.on('close', () => {
if (!answered) {
answered = true;
console.log(`\n ${yellow}Installation cancelled${reset}\n`);
process.exit(0);
}
});
console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}
${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset}
${cyan}2${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - open source, free models
${cyan}3${reset}) Both
`);
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
answered = true;
rl.close();
const choice = answer.trim() || '1';
if (choice === '3') {
callback(['claude', 'opencode']);
} else if (choice === '2') {
callback(['opencode']);
} else {
callback(['claude']);
}
});
}
/**
* Prompt for install location
* @param {string[]} runtimes - Array of runtimes to install for
*/
function promptLocation(runtimes) {
// Check if stdin is a TTY - if not, fall back to global install
// This handles npx execution in environments like WSL2 where stdin may not be properly connected
if (!process.stdin.isTTY) {
console.log(` ${yellow}Non-interactive terminal detected, defaulting to global install${reset}\n`);
installAllRuntimes(runtimes, true, false);
return;
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// Track whether we've processed the answer to prevent double-execution
let answered = false;
// Handle readline close event (Ctrl+C, Escape, etc.) - cancel installation
rl.on('close', () => {
if (!answered) {
answered = true;
console.log(`\n ${yellow}Installation cancelled${reset}\n`);
process.exit(0);
}
});
// Show paths for selected runtimes
const pathExamples = runtimes.map(r => {
// Use the proper global directory function for each runtime
const globalPath = getGlobalDir(r, explicitConfigDir);
return globalPath.replace(os.homedir(), '~');
}).join(', ');
const localExamples = runtimes.map(r => `./${getDirName(r)}`).join(', ');
console.log(` ${yellow}Where would you like to install?${reset}
${cyan}1${reset}) Global ${dim}(${pathExamples})${reset} - available in all projects
${cyan}2${reset}) Local ${dim}(${localExamples})${reset} - this project only
`);
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
answered = true;
rl.close();
const choice = answer.trim() || '1';
const isGlobal = choice !== '2';
installAllRuntimes(runtimes, isGlobal, true);
});
}
/**
* Install GSD for all selected runtimes
* @param {string[]} runtimes - Array of runtimes to install for
* @param {boolean} isGlobal - Whether to install globally
* @param {boolean} isInteractive - Whether running interactively
*/
function installAllRuntimes(runtimes, isGlobal, isInteractive) {
const results = [];
for (const runtime of runtimes) {
const result = install(isGlobal, runtime);
results.push(result);
}
// Handle statusline for Claude Code only (OpenCode uses themes)
const claudeResult = results.find(r => r.runtime === 'claude');
if (claudeResult) {
handleStatusline(claudeResult.settings, isInteractive, (shouldInstallStatusline) => {
finishInstall(claudeResult.settingsPath, claudeResult.settings, claudeResult.statuslineCommand, shouldInstallStatusline, 'claude');
// Finish OpenCode install if present
const opencodeResult = results.find(r => r.runtime === 'opencode');
if (opencodeResult) {
finishInstall(opencodeResult.settingsPath, opencodeResult.settings, opencodeResult.statuslineCommand, false, 'opencode');
}
});
} else {
// Only OpenCode
const opencodeResult = results[0];
finishInstall(opencodeResult.settingsPath, opencodeResult.settings, opencodeResult.statuslineCommand, false, 'opencode');
}
}
// 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 (hasUninstall) {
// Uninstall mode
if (!hasGlobal && !hasLocal) {
console.error(` ${yellow}--uninstall requires --global or --local${reset}`);
console.error(` Example: npx get-shit-done-cc --claude --global --uninstall`);
process.exit(1);
}
const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : ['claude'];
for (const runtime of runtimes) {
uninstall(hasGlobal, runtime);
}
} else if (selectedRuntimes.length > 0) {
// Non-interactive: runtime specified via flags
if (!hasGlobal && !hasLocal) {
// Need location but runtime is specified - prompt for location only
promptLocation(selectedRuntimes);
} else {
// Both runtime and location specified via flags
installAllRuntimes(selectedRuntimes, hasGlobal, false);
}
} else if (hasGlobal || hasLocal) {
// Location specified but no runtime - default to Claude Code
installAllRuntimes(['claude'], hasGlobal, false);
} else {
// Fully interactive: prompt for runtime, then location
if (!process.stdin.isTTY) {
console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code global install${reset}\n`);
installAllRuntimes(['claude'], true, false);
} else {
promptRuntime((runtimes) => {
promptLocation(runtimes);
});
}
}