Skip to content

Commit 1e26882

Browse files
author
Ethan Hurst
committed
fix: replace $HOME/.claude/ paths during local install (#820)
The installer only rewrote ~/.claude/ but missed the $HOME/.claude/ variant used in bash command contexts, causing MODULE_NOT_FOUND errors for local installs. Centralizes all path replacement into replacePathPatterns() and applies it consistently across all 5 copy sites.
1 parent 1c58e84 commit 1e26882

File tree

3 files changed

+142
-21
lines changed

3 files changed

+142
-21
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
### Fixed
10+
- `$HOME/.claude/` paths not replaced during local install, causing `MODULE_NOT_FOUND` on projects outside `$HOME` (#820)
11+
912
## [1.22.0] - 2026-02-27
1013

1114
### Added

bin/install.js

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ function getDirName(runtime) {
6666
return '.claude';
6767
}
6868

69+
// Centralized path replacement for all install targets.
70+
// Handles ~/.claude/, $HOME/.claude/, ./.claude/, and runtime-specific variants.
71+
function replacePathPatterns(content, pathPrefix, runtime) {
72+
const dirName = getDirName(runtime);
73+
content = content.replace(/~\/\.claude\//g, pathPrefix);
74+
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
75+
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
76+
if (runtime === 'opencode') {
77+
content = content.replace(/~\/\.opencode\//g, pathPrefix);
78+
} else if (runtime === 'codex') {
79+
content = content.replace(/~\/\.codex\//g, pathPrefix);
80+
}
81+
return content;
82+
}
83+
6984
/**
7085
* Get the config directory path relative to home directory for a runtime
7186
* Used for templating hooks that use path.join(homeDir, '<configDir>', ...)
@@ -983,12 +998,7 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
983998
const destPath = path.join(destDir, destName);
984999

9851000
let content = fs.readFileSync(srcPath, 'utf8');
986-
const globalClaudeRegex = /~\/\.claude\//g;
987-
const localClaudeRegex = /\.\/\.claude\//g;
988-
const opencodeDirRegex = /~\/\.opencode\//g;
989-
content = content.replace(globalClaudeRegex, pathPrefix);
990-
content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
991-
content = content.replace(opencodeDirRegex, pathPrefix);
1001+
content = replacePathPatterns(content, pathPrefix, runtime);
9921002
content = processAttribution(content, getCommitAttribution(runtime));
9931003
content = convertClaudeToOpencodeFrontmatter(content);
9941004

@@ -1042,12 +1052,7 @@ function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtim
10421052
fs.mkdirSync(skillDir, { recursive: true });
10431053

10441054
let content = fs.readFileSync(srcPath, 'utf8');
1045-
const globalClaudeRegex = /~\/\.claude\//g;
1046-
const localClaudeRegex = /\.\/\.claude\//g;
1047-
const codexDirRegex = /~\/\.codex\//g;
1048-
content = content.replace(globalClaudeRegex, pathPrefix);
1049-
content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
1050-
content = content.replace(codexDirRegex, pathPrefix);
1055+
content = replacePathPatterns(content, pathPrefix, runtime);
10511056
content = processAttribution(content, getCommitAttribution(runtime));
10521057
content = convertClaudeCommandToCodexSkill(content, skillName);
10531058

@@ -1069,7 +1074,6 @@ function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtim
10691074
function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false) {
10701075
const isOpencode = runtime === 'opencode';
10711076
const isCodex = runtime === 'codex';
1072-
const dirName = getDirName(runtime);
10731077

10741078
// Clean install: remove existing destination to prevent orphaned files
10751079
if (fs.existsSync(destDir)) {
@@ -1086,12 +1090,9 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
10861090
if (entry.isDirectory()) {
10871091
copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand);
10881092
} else if (entry.name.endsWith('.md')) {
1089-
// Replace ~/.claude/ and ./.claude/ with runtime-appropriate paths
1093+
// Replace ~/.claude/, $HOME/.claude/, ./.claude/ with runtime-appropriate paths
10901094
let content = fs.readFileSync(srcPath, 'utf8');
1091-
const globalClaudeRegex = /~\/\.claude\//g;
1092-
const localClaudeRegex = /\.\/\.claude\//g;
1093-
content = content.replace(globalClaudeRegex, pathPrefix);
1094-
content = content.replace(localClaudeRegex, `./${dirName}/`);
1095+
content = replacePathPatterns(content, pathPrefix, runtime);
10951096
content = processAttribution(content, getCommitAttribution(runtime));
10961097

10971098
// Convert frontmatter for opencode compatibility
@@ -1929,9 +1930,7 @@ function install(isGlobal, runtime = 'claude') {
19291930
for (const entry of agentEntries) {
19301931
if (entry.isFile() && entry.name.endsWith('.md')) {
19311932
let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
1932-
// Always replace ~/.claude/ as it is the source of truth in the repo
1933-
const dirRegex = /~\/\.claude\//g;
1934-
content = content.replace(dirRegex, pathPrefix);
1933+
content = replacePathPatterns(content, pathPrefix, runtime);
19351934
content = processAttribution(content, getCommitAttribution(runtime));
19361935
// Convert frontmatter for runtime compatibility
19371936
if (isOpencode) {
@@ -2333,6 +2332,7 @@ if (process.env.GSD_TEST_MODE) {
23332332
convertClaudeCommandToCodexSkill,
23342333
GSD_CODEX_MARKER,
23352334
CODEX_AGENT_SANDBOX,
2335+
replacePathPatterns,
23362336
};
23372337
} else {
23382338

tests/path-replacement.test.cjs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* GSD Tests - path-replacement.test.cjs
3+
*
4+
* Tests for replacePathPatterns() — centralised path replacement
5+
* that handles ~/.claude/, $HOME/.claude/, ./.claude/, and
6+
* runtime-specific variants during install.
7+
*/
8+
9+
process.env.GSD_TEST_MODE = '1';
10+
11+
const { test, describe } = require('node:test');
12+
const assert = require('node:assert');
13+
14+
const { replacePathPatterns } = require('../bin/install.js');
15+
16+
// ─── Basic replacements ──────────────────────────────────────────────────────
17+
18+
describe('replacePathPatterns', () => {
19+
const globalPrefix = '/Users/x/.claude/';
20+
21+
test('replaces ~/.claude/ with pathPrefix', () => {
22+
const input = 'Read ~/.claude/commands/foo.md';
23+
const result = replacePathPatterns(input, globalPrefix, 'claude');
24+
assert.strictEqual(result, 'Read /Users/x/.claude/commands/foo.md');
25+
});
26+
27+
test('replaces $HOME/.claude/ with pathPrefix (bug fix)', () => {
28+
const input = 'cat $HOME/.claude/commands/foo.md';
29+
const result = replacePathPatterns(input, globalPrefix, 'claude');
30+
assert.strictEqual(result, 'cat /Users/x/.claude/commands/foo.md');
31+
});
32+
33+
test('replaces ./.claude/ with local dir for claude runtime', () => {
34+
const input = 'Read ./.claude/commands/foo.md';
35+
const result = replacePathPatterns(input, globalPrefix, 'claude');
36+
assert.strictEqual(result, 'Read ./.claude/commands/foo.md');
37+
});
38+
39+
test('replaces ./.claude/ with ./.opencode/ for opencode runtime', () => {
40+
const input = 'Read ./.claude/commands/foo.md';
41+
const result = replacePathPatterns(input, '/home/user/.config/opencode/', 'opencode');
42+
assert.strictEqual(result, 'Read ./.opencode/commands/foo.md');
43+
});
44+
45+
test('replaces ./.claude/ with ./.codex/ for codex runtime', () => {
46+
const input = 'Read ./.claude/commands/foo.md';
47+
const result = replacePathPatterns(input, '/home/user/.codex/', 'codex');
48+
assert.strictEqual(result, 'Read ./.codex/commands/foo.md');
49+
});
50+
51+
test('replaces ./.claude/ with ./.kimi/ for kimi runtime', () => {
52+
const input = 'Read ./.claude/commands/foo.md';
53+
const result = replacePathPatterns(input, '/home/user/.kimi/', 'kimi');
54+
assert.strictEqual(result, 'Read ./.kimi/commands/foo.md');
55+
});
56+
57+
// ─── Runtime-specific variants ─────────────────────────────────────────────
58+
59+
test('replaces ~/.opencode/ only when runtime is opencode', () => {
60+
const prefix = '/home/user/.config/opencode/';
61+
assert.strictEqual(
62+
replacePathPatterns('path ~/.opencode/foo', prefix, 'opencode'),
63+
'path /home/user/.config/opencode/foo'
64+
);
65+
// Should NOT replace when runtime is claude
66+
assert.strictEqual(
67+
replacePathPatterns('path ~/.opencode/foo', globalPrefix, 'claude'),
68+
'path ~/.opencode/foo'
69+
);
70+
});
71+
72+
test('replaces ~/.codex/ only when runtime is codex', () => {
73+
const prefix = '/home/user/.codex/';
74+
assert.strictEqual(
75+
replacePathPatterns('path ~/.codex/foo', prefix, 'codex'),
76+
'path /home/user/.codex/foo'
77+
);
78+
// Should NOT replace when runtime is claude
79+
assert.strictEqual(
80+
replacePathPatterns('path ~/.codex/foo', globalPrefix, 'claude'),
81+
'path ~/.codex/foo'
82+
);
83+
});
84+
85+
// ─── Pass-through ──────────────────────────────────────────────────────────
86+
87+
test('non-matching content passes through unchanged', () => {
88+
const input = 'No paths here, just text.';
89+
assert.strictEqual(
90+
replacePathPatterns(input, globalPrefix, 'claude'),
91+
input
92+
);
93+
});
94+
95+
// ─── Multiple patterns in one string ───────────────────────────────────────
96+
97+
test('replaces all pattern types in mixed content', () => {
98+
const input = [
99+
'global: ~/.claude/agents/foo.md',
100+
'env: $HOME/.claude/agents/bar.md',
101+
'local: ./.claude/commands/baz.md',
102+
].join('\n');
103+
104+
const result = replacePathPatterns(input, globalPrefix, 'claude');
105+
106+
assert.strictEqual(result, [
107+
'global: /Users/x/.claude/agents/foo.md',
108+
'env: /Users/x/.claude/agents/bar.md',
109+
'local: ./.claude/commands/baz.md',
110+
].join('\n'));
111+
});
112+
113+
test('replaces multiple occurrences of the same pattern', () => {
114+
const input = '$HOME/.claude/a and $HOME/.claude/b';
115+
const result = replacePathPatterns(input, globalPrefix, 'claude');
116+
assert.strictEqual(result, '/Users/x/.claude/a and /Users/x/.claude/b');
117+
});
118+
});

0 commit comments

Comments
 (0)