Read the script
#!/usr/bin/env node
// gsd-hook-version: {{GSD_VERSION}}
//
// gsd-ensure-canonical-path — SessionStart hook (#997)
//
// PROBLEM: GSD agents/commands/templates use markdown `@`-file-includes that
// hardcode the canonical path `@~/.claude/gsd-core/...` (references, workflows,
// templates, contexts, bin). Markdown @-includes expand `~` but do NOT expand
// environment variables, so `${CLAUDE_PLUGIN_ROOT}` cannot be used in them.
// In a classic `bin/install.js` install the canonical path is a real directory
// holding the bundled tree, so the includes resolve. In a Claude Code
// *marketplace plugin* install the plugin manager only unpacks the package
// into the version-pinned plugin cache and never runs `bin/install.js`, so
// `~/.claude/gsd-core/` is never created and every @-include resolves to
// nothing — every agent that depends on one fails (e.g. the executor).
//
// FIX: On SessionStart, when running under a plugin install (CLAUDE_PLUGIN_ROOT
// set and a bundled `gsd-core/` tree found beneath it), ensure
// `~/.claude/gsd-core/` exists and its immutable subdirs (bin, contexts,
// references, templates, workflows) are symlinked to the plugin's bundled tree.
// This changes ZERO @-references, is a no-op in classic installs (where each
// subdir is already a real directory), preserves user-generated files
// (USER-PROFILE.md, STATE.md, VERSION, …), prunes stale links so it self-heals
// after `claude plugin update` rotates the version dir, and uses Windows
// junctions for symlinks on win32.
//
// SECURITY: the resolved bundled-tree path and every per-subdir link target are
// kept strictly inside the resolved plugin root (realpath-normalised, prefix-
// checked). A real (non-symlink) file or directory already sitting at a managed
// link target is NEVER clobbered.
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { allow } = require('./lib/hook-exit.js');
// Immutable, bundled subdirectories that the canonical path must expose. These
// are the directories `@~/.claude/gsd-core/<subdir>/...` includes point into.
// User-generated artifacts (USER-PROFILE.md, STATE.md, VERSION, config, …) are
// NOT in this list and are never created, moved, or deleted by this hook.
const MANAGED_SUBDIRS = ['bin', 'contexts', 'references', 'templates', 'workflows'];
/**
* Resolve the canonical runtime config dir for the active runtime.
*
* Honours CLAUDE_CONFIG_DIR for custom/multi-account setups (mirrors
* gsd-check-update.js detectConfigDir), else falls back to ~/.claude. The
* canonical GSD tree always lives at `<configDir>/gsd-core`.
*/
function resolveConfigDir(homeDir, env) {
const envDir = env.CLAUDE_CONFIG_DIR;
if (envDir && typeof envDir === 'string' && envDir.trim().length > 0) {
return envDir;
}
return path.join(homeDir, '.claude');
}
/**
* Locate the bundled `gsd-core/` tree beneath a plugin root.
*
* Claude Code unpacks the package so the bundled tree sits at
* `<pluginRoot>/gsd-core/`. Returns the absolute, realpath-normalised path to
* that directory, or null if it is absent / not a directory. Resolving with
* realpath collapses symlinks/.. so the subsequent containment check is sound.
*/
function resolveBundledTree(pluginRoot) {
if (!pluginRoot || typeof pluginRoot !== 'string' || pluginRoot.trim().length === 0) {
return null;
}
let root;
try {
root = fs.realpathSync(pluginRoot);
} catch (_) {
return null; // plugin root does not exist
}
const bundled = path.join(root, 'gsd-core');
let bundledReal;
try {
// The bundled tree must be a real directory (or a symlink to one) that
// resolves to a path inside the plugin root. realpathSync throws ENOENT/
// ENOTDIR if <pluginRoot>/gsd-core is absent, so no separate existence
// check is needed. Reject anything that does not resolve to a directory.
bundledReal = fs.realpathSync(bundled);
if (!fs.statSync(bundledReal).isDirectory()) return null;
} catch (_) {
return null;
}
// SECURITY: the resolved bundled tree must stay inside the resolved plugin
// root. A crafted symlink at <pluginRoot>/gsd-core pointing outside the root
// is rejected — we never link the canonical path at content we do not own.
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
if (bundledReal !== root && !bundledReal.startsWith(rootWithSep)) {
return null;
}
return bundledReal;
}
/**
* The fs.symlinkSync `type` to use for a directory link on a given platform.
*
* On Windows, unprivileged users cannot create symlinks but CAN create
* junctions; 'junction' requires an absolute target (we always pass one). On
* POSIX a 'dir' symlink is used. Exported so the win32 branch is unit-testable
* without a Windows host.
*/
function dirLinkType(platform) {
return platform === 'win32' ? 'junction' : 'dir';
}
/**
* Create a directory symlink (junction on win32) from linkPath -> target.
* Throws on real failure so the caller records it.
*/
function createDirLink(target, linkPath, platform) {
fs.symlinkSync(target, linkPath, dirLinkType(platform));
}
/**
* Does `linkPath` already correctly point at `expectedTarget`?
* Used to make the hook idempotent — a correct link is left untouched.
*/
function linkPointsAt(linkPath, expectedTarget) {
try {
if (!fs.lstatSync(linkPath).isSymbolicLink()) return false;
const resolved = fs.realpathSync(linkPath);
return resolved === fs.realpathSync(expectedTarget);
} catch (_) {
return false;
}
}
/**
* Ensure the canonical `~/.claude/gsd-core/` path exposes the bundled subdirs.
*
* Pure, dependency-injected core so tests drive it with a fake home, fake
* plugin root, and explicit platform. Returns a structured result describing
* exactly what happened (never throws for ordinary conditions — only truly
* unexpected I/O errors propagate, and the thin CLI wrapper swallows those so
* a hook failure never blocks a session).
*
*