Skip to content

Commit 6213d90

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 751ee00 commit 6213d90

File tree

3 files changed

+141
-23
lines changed

3 files changed

+141
-23
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
77
## [Unreleased]
88

99
### Fixed
10+
- `$HOME/.claude/` paths not replaced during local install, causing `MODULE_NOT_FOUND` on projects outside `$HOME` (#820)
1011
- **Quality/balanced profiles now deliver Opus subagents**`resolveModelInternal` previously
1112
converted `opus` to `inherit`, causing agents to silently run on Sonnet when the parent
1213
session used the default Sonnet 4.6 model. Opus is now passed directly to Task calls (#695)

bin/install.js

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,21 @@ function getDirName(runtime) {
6969
return '.claude';
7070
}
7171

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

11181133
let content = fs.readFileSync(srcPath, 'utf8');
1119-
const globalClaudeRegex = /~\/\.claude\//g;
1120-
const localClaudeRegex = /\.\/\.claude\//g;
1121-
const opencodeDirRegex = /~\/\.opencode\//g;
1122-
content = content.replace(globalClaudeRegex, pathPrefix);
1123-
content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
1124-
content = content.replace(opencodeDirRegex, pathPrefix);
1134+
content = replacePathPatterns(content, pathPrefix, runtime);
11251135
content = processAttribution(content, getCommitAttribution(runtime));
11261136
content = convertClaudeToOpencodeFrontmatter(content);
11271137

@@ -1175,12 +1185,7 @@ function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtim
11751185
fs.mkdirSync(skillDir, { recursive: true });
11761186

11771187
let content = fs.readFileSync(srcPath, 'utf8');
1178-
const globalClaudeRegex = /~\/\.claude\//g;
1179-
const localClaudeRegex = /\.\/\.claude\//g;
1180-
const codexDirRegex = /~\/\.codex\//g;
1181-
content = content.replace(globalClaudeRegex, pathPrefix);
1182-
content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
1183-
content = content.replace(codexDirRegex, pathPrefix);
1188+
content = replacePathPatterns(content, pathPrefix, runtime);
11841189
content = processAttribution(content, getCommitAttribution(runtime));
11851190
content = convertClaudeCommandToCodexSkill(content, skillName);
11861191

@@ -1220,8 +1225,7 @@ function copyCommandsAsKimiSkills(srcDir, skillsDir, prefix, pathPrefix, runtime
12201225
fs.mkdirSync(skillDir, { recursive: true });
12211226

12221227
let content = fs.readFileSync(srcPath, 'utf8');
1223-
content = content.replace(/~\/\.claude\//g, pathPrefix);
1224-
content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
1228+
content = replacePathPatterns(content, pathPrefix, runtime);
12251229
content = processAttribution(content, getCommitAttribution(runtime));
12261230
content = convertClaudeToKimiSkill(content, skillName);
12271231

@@ -1243,7 +1247,6 @@ function copyCommandsAsKimiSkills(srcDir, skillsDir, prefix, pathPrefix, runtime
12431247
function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false) {
12441248
const isOpencode = runtime === 'opencode';
12451249
const isCodex = runtime === 'codex';
1246-
const dirName = getDirName(runtime);
12471250

12481251
// Clean install: remove existing destination to prevent orphaned files
12491252
if (fs.existsSync(destDir)) {
@@ -1260,12 +1263,9 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
12601263
if (entry.isDirectory()) {
12611264
copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand);
12621265
} else if (entry.name.endsWith('.md')) {
1263-
// Replace ~/.claude/ and ./.claude/ with runtime-appropriate paths
1266+
// Replace ~/.claude/, $HOME/.claude/, ./.claude/ with runtime-appropriate paths
12641267
let content = fs.readFileSync(srcPath, 'utf8');
1265-
const globalClaudeRegex = /~\/\.claude\//g;
1266-
const localClaudeRegex = /\.\/\.claude\//g;
1267-
content = content.replace(globalClaudeRegex, pathPrefix);
1268-
content = content.replace(localClaudeRegex, `./${dirName}/`);
1268+
content = replacePathPatterns(content, pathPrefix, runtime);
12691269
content = processAttribution(content, getCommitAttribution(runtime));
12701270

12711271
// Convert frontmatter for opencode compatibility
@@ -2145,9 +2145,7 @@ function install(isGlobal, runtime = 'claude') {
21452145
for (const entry of agentEntries) {
21462146
if (entry.isFile() && entry.name.endsWith('.md')) {
21472147
let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
2148-
// Always replace ~/.claude/ as it is the source of truth in the repo
2149-
const dirRegex = /~\/\.claude\//g;
2150-
content = content.replace(dirRegex, pathPrefix);
2148+
content = replacePathPatterns(content, pathPrefix, runtime);
21512149
content = processAttribution(content, getCommitAttribution(runtime));
21522150
// Convert frontmatter for runtime compatibility
21532151
if (isOpencode) {
@@ -2577,6 +2575,7 @@ if (process.env.GSD_TEST_MODE) {
25772575
convertClaudeToKimiAgent,
25782576
convertKimiToolName,
25792577
copyCommandsAsKimiSkills,
2578+
replacePathPatterns,
25802579
};
25812580
} else {
25822581

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)