From e2b6179ba7e434621b063f2b8b67dae3ebca7b7f Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Tue, 3 Mar 2026 10:12:59 -0600 Subject: [PATCH] fix(install): replace $HOME/.claude paths for non-Claude runtimes The installer only replaced ~/.claude/ (tilde form) when rewriting paths for OpenCode, Gemini, and Codex installs. Source files also use $HOME/.claude/ in bash code blocks (since ~ doesn't expand inside double-quoted strings), leaving ~175 unreplaced references that break gsd-tools.cjs invocations on non-Claude runtimes. Adds $HOME/.claude/ replacement to all 6 path-rewriting code paths, a toHomePrefix() utility to keep $HOME as a portable shell variable, and a post-install scan that warns if any .claude references leak through. Closes #905 Co-Authored-By: Claude Opus 4.6 --- bin/install.js | 71 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/bin/install.js b/bin/install.js index 962ea2eb3..b7f11e4cf 100755 --- a/bin/install.js +++ b/bin/install.js @@ -58,6 +58,21 @@ if (hasAll) { if (hasCodex) selectedRuntimes.push('codex'); } +/** + * Convert a pathPrefix (which uses absolute paths for global installs) to a + * $HOME-relative form for replacing $HOME/.claude/ references in bash code blocks. + * Preserves $HOME as a shell variable so paths remain portable across machines. + */ +function toHomePrefix(pathPrefix) { + const home = os.homedir().replace(/\\/g, '/'); + const normalized = pathPrefix.replace(/\\/g, '/'); + if (normalized.startsWith(home)) { + return '$HOME' + normalized.slice(home.length); + } + // For relative paths or paths not under $HOME, return as-is + return normalized; +} + // Helper to get directory name for a runtime (used for local/project installs) function getDirName(runtime) { if (runtime === 'opencode') return '.opencode'; @@ -700,8 +715,14 @@ function installCodexConfig(targetDir, agentsSrc) { const agentEntries = fs.readdirSync(agentsSrc).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); const agents = []; + // Compute the Codex pathPrefix for replacing .claude paths + const codexPathPrefix = `${targetDir.replace(/\\/g, '/')}/`; + for (const file of agentEntries) { - const content = fs.readFileSync(path.join(agentsSrc, file), 'utf8'); + let content = fs.readFileSync(path.join(agentsSrc, file), 'utf8'); + // Replace .claude paths before generating TOML (source files use ~/.claude and $HOME/.claude) + content = content.replace(/~\/\.claude\//g, codexPathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, toHomePrefix(codexPathPrefix)); const { frontmatter } = extractFrontmatterAndBody(content); const name = extractFrontmatterField(frontmatter, 'name') || file.replace('.md', ''); const description = extractFrontmatterField(frontmatter, 'description') || ''; @@ -823,8 +844,9 @@ function convertClaudeToOpencodeFrontmatter(content) { 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) + // Replace ~/.claude and $HOME/.claude with OpenCode's config location convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/opencode'); + convertedContent = convertedContent.replace(/\$HOME\/\.claude\b/g, '$HOME/.config/opencode'); // Replace general-purpose subagent type with OpenCode's equivalent "general" convertedContent = convertedContent.replace(/subagent_type="general-purpose"/g, 'subagent_type="general"'); @@ -1006,9 +1028,11 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) { let content = fs.readFileSync(srcPath, 'utf8'); const globalClaudeRegex = /~\/\.claude\//g; + const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; const localClaudeRegex = /\.\/\.claude\//g; const opencodeDirRegex = /~\/\.opencode\//g; content = content.replace(globalClaudeRegex, pathPrefix); + content = content.replace(globalClaudeHomeRegex, toHomePrefix(pathPrefix)); content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); content = content.replace(opencodeDirRegex, pathPrefix); content = processAttribution(content, getCommitAttribution(runtime)); @@ -1065,9 +1089,11 @@ function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtim let content = fs.readFileSync(srcPath, 'utf8'); const globalClaudeRegex = /~\/\.claude\//g; + const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; const localClaudeRegex = /\.\/\.claude\//g; const codexDirRegex = /~\/\.codex\//g; content = content.replace(globalClaudeRegex, pathPrefix); + content = content.replace(globalClaudeHomeRegex, toHomePrefix(pathPrefix)); content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); content = content.replace(codexDirRegex, pathPrefix); content = processAttribution(content, getCommitAttribution(runtime)); @@ -1108,11 +1134,13 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand if (entry.isDirectory()) { copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand); } else if (entry.name.endsWith('.md')) { - // Replace ~/.claude/ and ./.claude/ with runtime-appropriate paths + // Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths let content = fs.readFileSync(srcPath, 'utf8'); const globalClaudeRegex = /~\/\.claude\//g; + const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; const localClaudeRegex = /\.\/\.claude\//g; content = content.replace(globalClaudeRegex, pathPrefix); + content = content.replace(globalClaudeHomeRegex, toHomePrefix(pathPrefix)); content = content.replace(localClaudeRegex, `./${dirName}/`); content = processAttribution(content, getCommitAttribution(runtime)); @@ -1953,9 +1981,11 @@ function install(isGlobal, runtime = 'claude') { for (const entry of agentEntries) { if (entry.isFile() && entry.name.endsWith('.md')) { let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8'); - // Always replace ~/.claude/ as it is the source of truth in the repo + // Replace ~/.claude/ and $HOME/.claude/ as they are the source of truth in the repo const dirRegex = /~\/\.claude\//g; + const homeDirRegex = /\$HOME\/\.claude\//g; content = content.replace(dirRegex, pathPrefix); + content = content.replace(homeDirRegex, toHomePrefix(pathPrefix)); content = processAttribution(content, getCommitAttribution(runtime)); // Convert frontmatter for runtime compatibility if (isOpencode) { @@ -2046,6 +2076,39 @@ function install(isGlobal, runtime = 'claude') { // Report any backed-up local patches reportLocalPatches(targetDir, runtime); + // Verify no leaked .claude paths in non-Claude runtimes + if (runtime !== 'claude') { + const leakedPaths = []; + function scanForLeakedPaths(dir) { + if (!fs.existsSync(dir)) return; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + scanForLeakedPaths(fullPath); + } else if ((entry.name.endsWith('.md') || entry.name.endsWith('.toml')) && entry.name !== 'CHANGELOG.md') { + const content = fs.readFileSync(fullPath, 'utf8'); + const matches = content.match(/(?:~|\$HOME)\/\.claude\b/g); + if (matches) { + leakedPaths.push({ file: fullPath.replace(targetDir + '/', ''), count: matches.length }); + } + } + } + } + scanForLeakedPaths(targetDir); + if (leakedPaths.length > 0) { + const totalLeaks = leakedPaths.reduce((sum, l) => sum + l.count, 0); + console.warn(`\n ${yellow}⚠${reset} Found ${totalLeaks} unreplaced .claude path reference(s) in ${leakedPaths.length} file(s):`); + for (const leak of leakedPaths.slice(0, 5)) { + console.warn(` ${dim}${leak.file}${reset} (${leak.count})`); + } + if (leakedPaths.length > 5) { + console.warn(` ${dim}... and ${leakedPaths.length - 5} more file(s)${reset}`); + } + console.warn(` ${dim}These paths may not resolve correctly for ${runtimeLabel}.${reset}`); + } + } + if (isCodex) { // Generate Codex config.toml and per-agent .toml files const agentCount = installCodexConfig(targetDir, agentsSrc);