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 <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson
2026-03-03 10:12:59 -06:00
parent 40be3b055f
commit e2b6179ba7

View File

@@ -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);