Merge pull request #224 from dbachelder/opencode
feat(opencode): make installer work for opencode
This commit is contained in:
220
bin/install.js
220
bin/install.js
@@ -15,6 +15,13 @@ 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 claudeDirName = hasOpencode ? '.opencode' : '.claude'
|
||||
|
||||
const banner = `
|
||||
${cyan} ██████╗ ███████╗██████╗
|
||||
██╔════╝ ██╔════╝██╔══██╗
|
||||
@@ -25,14 +32,9 @@ ${cyan} ██████╗ ███████╗██████╗
|
||||
|
||||
Get Shit Done ${dim}v${pkg.version}${reset}
|
||||
A meta-prompting, context engineering and spec-driven
|
||||
development system for Claude Code by TÂCHES.
|
||||
development system for Claude Code (and opencode) 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');
|
||||
@@ -73,6 +75,7 @@ if (hasHelp) {
|
||||
${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
|
||||
${cyan}--opencode${reset} Install for opencode development
|
||||
|
||||
${yellow}Examples:${reset}
|
||||
${dim}# Install to default ~/.claude directory${reset}
|
||||
@@ -116,7 +119,7 @@ function buildHookCommand(claudeDir, hookName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse settings.json, returning empty object if doesn't exist
|
||||
* Read and parse settings.json, returning empty object if it doesn't exist
|
||||
*/
|
||||
function readSettings(settingsPath) {
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
@@ -136,6 +139,157 @@ 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
|
||||
convertedContent = convertedContent.replace(/\/gsd:/g, '/gsd/');
|
||||
// Replace ~/.claude with ~/.opencode
|
||||
convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy directory, replacing paths in .md files
|
||||
* Deletes existing destDir first to remove orphaned files from previous versions
|
||||
@@ -156,9 +310,14 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix) {
|
||||
if (entry.isDirectory()) {
|
||||
copyWithPathReplacement(srcPath, destPath, pathPrefix);
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
// Replace ~/.claude/ with the appropriate prefix in markdown files
|
||||
// Replace ~/.claude/ with the appropriate prefix in Markdown files
|
||||
let content = fs.readFileSync(srcPath, 'utf8');
|
||||
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
||||
const claudeDirRegex = new RegExp(`~/${claudeDirName.replace('.', '\\.')}/`, 'g');
|
||||
content = content.replace(claudeDirRegex, pathPrefix);
|
||||
// Convert frontmatter for opencode compatibility
|
||||
if (hasOpencode) {
|
||||
content = convertClaudeToOpencodeFrontmatter(content);
|
||||
}
|
||||
fs.writeFileSync(destPath, content);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
@@ -268,10 +427,10 @@ 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 defaultGlobalDir = configDir || path.join(os.homedir(), claudeDirName);
|
||||
const claudeDir = isGlobal
|
||||
? defaultGlobalDir
|
||||
: path.join(process.cwd(), '.claude');
|
||||
: path.join(process.cwd(), claudeDirName);
|
||||
|
||||
const locationLabel = isGlobal
|
||||
? claudeDir.replace(os.homedir(), '~')
|
||||
@@ -280,8 +439,8 @@ function install(isGlobal) {
|
||||
// Path prefix for file references
|
||||
// Use actual path when CLAUDE_CONFIG_DIR is set, otherwise use ~ shorthand
|
||||
const pathPrefix = isGlobal
|
||||
? (configDir ? `${claudeDir}/` : '~/.claude/')
|
||||
: './.claude/';
|
||||
? (configDir ? `${claudeDir}/` : `~/${claudeDirName}/`)
|
||||
: `./${claudeDirName}/`;
|
||||
|
||||
console.log(` Installing to ${cyan}${locationLabel}${reset}\n`);
|
||||
|
||||
@@ -336,7 +495,12 @@ function install(isGlobal) {
|
||||
for (const entry of agentEntries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
|
||||
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
||||
const claudeDirRegex = new RegExp(`~/${claudeDirName.replace('.', '\\.')}/`, 'g');
|
||||
content = content.replace(claudeDirRegex, pathPrefix);
|
||||
// Convert frontmatter for opencode compatibility
|
||||
if (hasOpencode) {
|
||||
content = convertClaudeToOpencodeFrontmatter(content);
|
||||
}
|
||||
fs.writeFileSync(path.join(agentsDest, entry.name), content);
|
||||
}
|
||||
}
|
||||
@@ -401,10 +565,10 @@ function install(isGlobal) {
|
||||
const settings = cleanupOrphanedHooks(readSettings(settingsPath));
|
||||
const statuslineCommand = isGlobal
|
||||
? buildHookCommand(claudeDir, 'gsd-statusline.js')
|
||||
: 'node .claude/hooks/gsd-statusline.js';
|
||||
: 'node ' + claudeDirName + '/hooks/gsd-statusline.js';
|
||||
const updateCheckCommand = isGlobal
|
||||
? buildHookCommand(claudeDir, 'gsd-check-update.js')
|
||||
: 'node .claude/hooks/gsd-check-update.js';
|
||||
: 'node ' + claudeDirName + '/hooks/gsd-check-update.js';
|
||||
|
||||
// Configure SessionStart hook for update checking
|
||||
if (!settings.hooks) {
|
||||
@@ -449,8 +613,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
||||
// Always write settings (hooks were already configured in install())
|
||||
writeSettings(settingsPath, settings);
|
||||
|
||||
let program = hasOpencode ? 'opencode' : `Claude Code`;
|
||||
console.log(`
|
||||
${green}Done!${reset} Launch Claude Code and run ${cyan}/gsd:help${reset}.
|
||||
${green}Done!${reset} Launch ${program} and run ${cyan}/gsd:help${reset}.
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -543,13 +708,13 @@ function promptLocation() {
|
||||
});
|
||||
|
||||
const configDir = expandTilde(explicitConfigDir) || expandTilde(process.env.CLAUDE_CONFIG_DIR);
|
||||
const globalPath = configDir || path.join(os.homedir(), '.claude');
|
||||
const globalPath = configDir || path.join(os.homedir(), claudeDirName);
|
||||
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
|
||||
${cyan}2${reset}) Local ${dim}(./${claudeDirName})${reset} - this project only
|
||||
`);
|
||||
|
||||
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
|
||||
@@ -572,14 +737,15 @@ if (hasGlobal && hasLocal) {
|
||||
} 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 - respect flags
|
||||
handleStatusline(settings, false, (shouldInstallStatusline) => {
|
||||
finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline);
|
||||
});
|
||||
} else if (hasLocal) {
|
||||
const { settingsPath, settings, statuslineCommand } = install(false);
|
||||
} else if (hasOpencode) {
|
||||
console.log(` Installing for opencode development...\n`);
|
||||
if (forceStatusline) {
|
||||
console.log(` ${yellow}Note: opencode doesn't support statusline configuration (uses themes instead)${reset}\n`);
|
||||
}
|
||||
const { settingsPath, settings, statuslineCommand } = install(hasGlobal);
|
||||
finishInstall(settingsPath, settings, statuslineCommand, false);
|
||||
} else if (hasGlobal || hasLocal) {
|
||||
const { settingsPath, settings, statuslineCommand } = install(hasGlobal);
|
||||
// Non-interactive - respect flags
|
||||
handleStatusline(settings, false, (shouldInstallStatusline) => {
|
||||
finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline);
|
||||
|
||||
Reference in New Issue
Block a user