Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` / `/Users/<name>` paths, so generated `.planning` files no longer leak local usernames into committed execution_context blocks (#987)

## [1.22.4] - 2026-03-03

### Added
Expand Down
53 changes: 44 additions & 9 deletions bin/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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, '<configDir>', ...)
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions tests/install-paths.test.cjs
Original file line number Diff line number Diff line change
@@ -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;
}
});
});