diff --git a/CHANGELOG.md b/CHANGELOG.md index 4923bd115b..a3c276f4a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed +- Claude global installs keep prompt file references home-relative instead of expanding to `C:/Users/` / `/Users/` paths, so generated `.planning` files no longer leak local usernames into committed execution_context blocks (#987) + ## [1.22.4] - 2026-03-03 ### Added diff --git a/bin/install.js b/bin/install.js index b7f11e4cf6..eac7d40e24 100755 --- a/bin/install.js +++ b/bin/install.js @@ -59,13 +59,29 @@ if (hasAll) { } /** - * 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. + * Convert an absolute in-home pathPrefix to a ~/ path when possible so Claude + * prompts stay portable and don't bake usernames into generated planning files. + */ +function toTildePrefix(pathPrefix) { + const home = os.homedir().replace(/\\/g, '/'); + const normalized = pathPrefix.replace(/\\/g, '/'); + if (normalized.startsWith(home)) { + return '~' + normalized.slice(home.length); + } + return normalized; +} + +/** + * Convert a pathPrefix 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('~/')) { + return '$HOME/' + normalized.slice(2); + } if (normalized.startsWith(home)) { return '$HOME' + normalized.slice(home.length); } @@ -81,6 +97,24 @@ function getDirName(runtime) { return '.claude'; } +/** + * Resolve the markdown path prefix that installed prompts should use for a + * runtime. Claude keeps global installs home-relative to avoid leaking local + * usernames into generated .planning artifacts. + */ +function getPathPrefix(runtime, isGlobal, targetDir) { + if (!isGlobal) { + return `./${getDirName(runtime)}/`; + } + + const absolutePrefix = `${targetDir.replace(/\\/g, '/')}/`; + if (runtime === 'claude') { + return toTildePrefix(absolutePrefix); + } + + return absolutePrefix; +} + /** * Get the config directory path relative to home directory for a runtime * Used for templating hooks that use path.join(homeDir, '', ...) @@ -1888,12 +1922,10 @@ function install(isGlobal, runtime = 'claude') { ? targetDir.replace(os.homedir(), '~') : targetDir.replace(process.cwd(), '.'); - // Path prefix for file references in markdown content - // For global installs: use full path - // For local installs: use relative - const pathPrefix = isGlobal - ? `${targetDir.replace(/\\/g, '/')}/` - : `./${dirName}/`; + // Path prefix for file references in markdown content. + // Claude keeps global installs home-relative to avoid leaking usernames + // into generated planning artifacts; other runtimes still use concrete paths. + const pathPrefix = getPathPrefix(runtime, isGlobal, targetDir); let runtimeLabel = 'Claude Code'; if (isOpencode) runtimeLabel = 'OpenCode'; @@ -2411,6 +2443,9 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { // Test-only exports — skip main logic when loaded as a module for testing if (process.env.GSD_TEST_MODE) { module.exports = { + toTildePrefix, + toHomePrefix, + getPathPrefix, getCodexSkillAdapterHeader, convertClaudeAgentToCodexAgent, generateCodexAgentToml, diff --git a/tests/install-paths.test.cjs b/tests/install-paths.test.cjs new file mode 100644 index 0000000000..1c0bed63ae --- /dev/null +++ b/tests/install-paths.test.cjs @@ -0,0 +1,49 @@ +const { test, describe } = require('node:test'); +const assert = require('node:assert'); +const os = require('node:os'); + +process.env.GSD_TEST_MODE = '1'; +const { toTildePrefix, toHomePrefix, getPathPrefix } = require('../bin/install.js'); + +const home = os.homedir().replace(/\\/g, '/'); + +describe('installer path privacy helpers', () => { + test('toTildePrefix collapses in-home absolute paths', () => { + assert.equal(toTildePrefix(`${home}/.claude/`), '~/.claude/'); + assert.equal(toTildePrefix(`${home}/custom/gsd/`), '~/custom/gsd/'); + }); + + test('toHomePrefix preserves tilde-based paths as $HOME references', () => { + assert.equal(toHomePrefix('~/.claude/'), '$HOME/.claude/'); + assert.equal(toHomePrefix('~/custom/gsd/'), '$HOME/custom/gsd/'); + }); + + test('getPathPrefix keeps global Claude installs home-relative', () => { + assert.equal(getPathPrefix('claude', true, `${home}/.claude`), '~/.claude/'); + }); + + test('getPathPrefix keeps non-Claude global installs concrete', () => { + assert.equal(getPathPrefix('codex', true, `${home}/.codex`), `${home}/.codex/`); + }); + + test('getPathPrefix keeps local installs runtime-relative', () => { + assert.equal(getPathPrefix('claude', false, '/ignored'), './.claude/'); + assert.equal(getPathPrefix('codex', false, '/ignored'), './.codex/'); + }); + + test('getPathPrefix leaves out-of-home Claude installs absolute', () => { + assert.equal(getPathPrefix('claude', true, '/opt/shared/claude'), '/opt/shared/claude/'); + }); + + test('Windows-style home paths also collapse to tilde/$HOME forms', () => { + const originalHomedir = os.homedir; + os.homedir = () => 'C:\\Users\\Nicole'; + try { + assert.equal(toTildePrefix('C:\\Users\\Nicole\\.claude\\'), '~/.claude/'); + assert.equal(toHomePrefix('~/.claude/'), '$HOME/.claude/'); + assert.equal(getPathPrefix('claude', true, 'C:\\Users\\Nicole\\.claude'), '~/.claude/'); + } finally { + os.homedir = originalHomedir; + } + }); +});