fix(install): template hook paths for OpenCode/Gemini runtimes (#585)

Hooks had hardcoded `.claude` paths that broke OpenCode users. The
installer now templates `.js` hooks with runtime-specific config dirs,
same as it already does for `.md` files. Also added `./.claude/`
replacement for local install paths in workflows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson
2026-02-15 08:40:04 -06:00
parent 7b140c2dd2
commit 9a7bb22ea7
3 changed files with 47 additions and 11 deletions

View File

@@ -46,6 +46,27 @@ function getDirName(runtime) {
return '.claude';
}
/**
* Get the config directory path relative to home directory for a runtime
* Used for templating hooks that use path.join(homeDir, '<configDir>', ...)
* @param {string} runtime - 'claude', 'opencode', or 'gemini'
* @param {boolean} isGlobal - Whether this is a global install
*/
function getConfigDirFromHome(runtime, isGlobal) {
if (!isGlobal) {
// Local installs use the same dir name pattern
return getDirName(runtime);
}
// Global installs - OpenCode uses XDG path structure
if (runtime === 'opencode') {
// OpenCode: ~/.config/opencode -> '.config', 'opencode'
// Return as comma-separated for path.join() replacement
return "'.config', 'opencode'";
}
if (runtime === 'gemini') return "'.gemini'";
return "'.claude'";
}
/**
* Get the global config directory for OpenCode
* OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
@@ -626,9 +647,11 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
const destPath = path.join(destDir, destName);
let content = fs.readFileSync(srcPath, 'utf8');
const claudeDirRegex = /~\/\.claude\//g;
const globalClaudeRegex = /~\/\.claude\//g;
const localClaudeRegex = /\.\/\.claude\//g;
const opencodeDirRegex = /~\/\.opencode\//g;
content = content.replace(claudeDirRegex, pathPrefix);
content = content.replace(globalClaudeRegex, pathPrefix);
content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
content = content.replace(opencodeDirRegex, pathPrefix);
content = processAttribution(content, getCommitAttribution(runtime));
content = convertClaudeToOpencodeFrontmatter(content);
@@ -665,10 +688,12 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) {
if (entry.isDirectory()) {
copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime);
} else if (entry.name.endsWith('.md')) {
// Always replace ~/.claude/ as it is the source of truth in the repo
// Replace ~/.claude/ and ./.claude/ with runtime-appropriate paths
let content = fs.readFileSync(srcPath, 'utf8');
const claudeDirRegex = /~\/\.claude\//g;
content = content.replace(claudeDirRegex, pathPrefix);
const globalClaudeRegex = /~\/\.claude\//g;
const localClaudeRegex = /\.\/\.claude\//g;
content = content.replace(globalClaudeRegex, pathPrefix);
content = content.replace(localClaudeRegex, `./${dirName}/`);
content = processAttribution(content, getCommitAttribution(runtime));
// Convert frontmatter for opencode compatibility
@@ -1429,16 +1454,25 @@ function install(isGlobal, runtime = 'claude') {
console.log(` ${green}✓${reset} Wrote package.json (CommonJS mode)`);
// Copy hooks from dist/ (bundled with dependencies)
// Template paths for the target runtime (replaces '.claude' with correct config dir)
const hooksSrc = path.join(src, 'hooks', 'dist');
if (fs.existsSync(hooksSrc)) {
const hooksDest = path.join(targetDir, 'hooks');
fs.mkdirSync(hooksDest, { recursive: true });
const hookEntries = fs.readdirSync(hooksSrc);
const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
for (const entry of hookEntries) {
const srcFile = path.join(hooksSrc, entry);
if (fs.statSync(srcFile).isFile()) {
const destFile = path.join(hooksDest, entry);
fs.copyFileSync(srcFile, destFile);
// Template .js files to replace '.claude' with runtime-specific config dir
if (entry.endsWith('.js')) {
let content = fs.readFileSync(srcFile, 'utf8');
content = content.replace(/'\.claude'/g, configDirReplacement);
fs.writeFileSync(destFile, content);
} else {
fs.copyFileSync(srcFile, destFile);
}
}
}
if (verifyInstalled(hooksDest, 'hooks')) {

View File

@@ -14,11 +14,11 @@ After a GSD update wipes and reinstalls files, this command merges user's previo
Check for local patches directory:
```bash
# Global install
PATCHES_DIR="${HOME}/.claude/gsd-local-patches"
# Global install (path templated at install time)
PATCHES_DIR=~/.claude/gsd-local-patches
# Local install fallback
if [ ! -d "$PATCHES_DIR" ]; then
PATCHES_DIR="./.claude/gsd-local-patches"
PATCHES_DIR=./.claude/gsd-local-patches
fi
```

View File

@@ -13,8 +13,9 @@ Detect whether GSD is installed locally or globally by checking both locations:
```bash
# Check local first (takes priority)
if [ -f "./.claude/get-shit-done/VERSION" ]; then
cat "./.claude/get-shit-done/VERSION"
# Paths templated at install time for runtime compatibility
if [ -f ./.claude/get-shit-done/VERSION ]; then
cat ./.claude/get-shit-done/VERSION
echo "LOCAL"
elif [ -f ~/.claude/get-shit-done/VERSION ]; then
cat ~/.claude/get-shit-done/VERSION
@@ -167,6 +168,7 @@ rm -f ./.claude/cache/gsd-update-check.json
```bash
rm -f ~/.claude/cache/gsd-update-check.json
```
(Paths are templated at install time for runtime compatibility)
</step>
<step name="display_result">