fix(#2565): route generate-claude-profile target through runtime policy (#2659)

* test(#2565): add failing regression for generate-claude-profile runtime target

cmdGenerateClaudeProfile hardcodes .claude/CLAUDE.md (project + global),
ignoring the runtime policy that #3163 wired into the sibling
cmdGenerateClaudeMd handler. These tests pin the parity contract for
project scope (codex -> AGENTS.md, env precedence, --output override,
claude preserved), global scope (codex -> <CODEX_HOME>/AGENTS.md, claude
preserved), and a divergence guard asserting both handlers agree on the
project instruction path for the same runtime.

Failing-first: all seven tests reproduce the bug on unmodified next.

* fix(#2565): route generate-claude-profile target through runtime policy

cmdGenerateClaudeProfile hardcoded .claude/CLAUDE.md for both project and
global scope, ignoring the runtime-aware resolution that #3163 wired into
the sibling cmdGenerateClaudeMd handler. The #3163 fix diverged when it did
not propagate here, so /gsd-profile-user kept writing Claude instruction
files on Codex installs (and other AGENTS-native runtimes: opencode, kilo,
kimi, antigravity, copilot).

Fix mirrors the proven #3163 pattern using existing policy primitives:
- Project scope resolves through getProjectInstructionFile(runtime) and a
  non-claude runtime wins over a stale claude_md_path (AGENTS-native
  projects must never write to CLAUDE.md).
- Global scope derives ~/.<config-home>/<instruction-basename> via
  getGlobalConfigDir + basename(getProjectInstructionFile), so codex lands
  at ~/.codex/AGENTS.md. Claude global is preserved byte-for-byte (no
  env-var drift beyond the prior hardcoded path).
- GSD_RUNTIME env var takes precedence over config.runtime.

A parity test asserts both handlers agree on the project instruction path
for the same runtime, guarding against future re-divergence
(CLAUDE.md 'Generative Fix Divergence' rule).

* docs(#2565): add changeset fragment for generate-claude-profile runtime fix

* docs(#2565): remove parenthetical product description from changeset

The product-name purity guard (#1777) flags 'ProductName (description)'
patterns in changeset fragments because the prose renders verbatim into
CHANGELOG.md at release time. The original fragment had 'Codex (and other
AGENTS-native runtimes)' in the bold header, which matched the banned
pattern. Reworded to drop the parenthetical; also trimmed the per-runtime
mapping (belongs in code comments, not changelog prose).

* docs(#2565): backfill PR number in changeset fragment

* test(#2565): isolate os.homedir() cross-platform in claude global test

The 'global scope: claude runtime writes to ~/.claude/CLAUDE.md' test set
only HOME to redirect os.homedir() at a tmpDir. On Windows, Node's
os.homedir() reads USERPROFILE (not HOME), so the child process still
resolved the real user profile and the path assertion failed (#2659 CI).
Set USERPROFILE alongside HOME so the isolation holds on both POSIX and
Windows. Production code is unchanged — it uses os.homedir() exactly as
the prior hardcoded path did.
This commit is contained in:
Tom Boucher
2026-07-26 01:06:35 -04:00
committed by GitHub
parent ec681e3c21
commit bf127b7d25
3 changed files with 243 additions and 7 deletions

View File

@@ -1028,20 +1028,52 @@ function cmdGenerateClaudeProfile(cwd: string, options: CmdGenerateClaudeProfile
const sectionContent = sectionLines.join('\n');
// #2565: resolve target through effective runtime policy instead of
// hardcoded .claude/CLAUDE.md. Mirrors the #3163 fix applied to
// cmdGenerateClaudeMd above; that fix diverged when it didn't propagate
// here, leaving /gsd-profile-user writing Claude files on Codex installs.
// - Project scope: getProjectInstructionFile(runtime) is the single source
// of truth (AGENTS.md for codex/opencode/kilo/kimi/unknown; GEMINI.md for
// antigravity; .github/copilot-instructions.md for copilot).
// - Global scope: ~/.<config-home>/<instruction-basename>, derived from
// getGlobalConfigDir + basename(getProjectInstructionFile), so codex lands
// at ~/.codex/AGENTS.md. Claude global is preserved byte-for-byte (no
// env-var drift beyond the prior hardcoded path).
// - GSD_RUNTIME env var takes precedence over config.runtime (mirrors the
// #3163 env-precedence contract). Non-claude always wins over a stale
// claude_md_path (#3163 rationale: an AGENTS-native project must never
// write to CLAUDE.md even if a prior Claude setup left claude_md_path).
let config: Record<string, unknown> = {};
try {
config = loadConfig(cwd);
} catch { /* use defaults */ }
const effectiveRuntime = resolveRuntimeNameFromCandidates(
process.env['GSD_RUNTIME'],
config['runtime'],
);
const isClaudeRuntime = !effectiveRuntime || effectiveRuntime === 'claude';
let targetPath: string;
if (options.global) {
targetPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
if (isClaudeRuntime) {
targetPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
} else {
targetPath = path.join(
getGlobalConfigDir(effectiveRuntime),
path.basename(getProjectInstructionFile(effectiveRuntime)),
);
}
} else if (options.output) {
targetPath = path.isAbsolute(options.output) ? options.output : path.join(cwd, options.output);
} else {
// Read claude_md_path from config; #1098 default is ./.claude/CLAUDE.md
// Read claude_md_path from config; #1098 default is .claude/CLAUDE.md
// (kept consistent with cmdGenerateClaudeMd so the profile section and the
// managed sections land in the same file on a config-less project).
let configClaudeMdPath = './.claude/CLAUDE.md';
try {
const config = loadConfig(cwd);
if (config['claude_md_path']) configClaudeMdPath = config['claude_md_path'] as string;
} catch { /* use default */ }
let configClaudeMdPath = '.claude/CLAUDE.md';
if (config['claude_md_path']) configClaudeMdPath = config['claude_md_path'] as string;
if (!isClaudeRuntime) {
configClaudeMdPath = getProjectInstructionFile(effectiveRuntime);
}
targetPath = path.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : path.join(cwd, configClaudeMdPath);
}