fix(#2395): persist runtime identity into ~/.gsd/defaults.json for non-Claude installs (#2446)

* fix(#2395): persist runtime identity into ~/.gsd/defaults.json for non-Claude installs

Bug: finishInstall for non-Claude runtimes never persisted a 'runtime' key
into ~/.gsd/defaults.json. resolveRuntime() precedence is GSD_RUNTIME env >
config.runtime > 'claude', so both inputs being empty on a Cursor (or any
non-Claude) install caused agent_runtime and every runtime-branded slash
hint to silently fall through to 'claude'. Cursor users saw
'agent_runtime: "claude"' and Claude-formatted /gsd-* hints with no env
or config hand-set.

Fix mirrors the existing resolve_model_ids: 'omit' write site at the same
call site (bin/install.js finishInstall, gated on !_hostBehaviors(runtime)
.nativeModelAliases && !GSD_TEST_MODE). Writes runtime: <runtime> into
~/.gsd/defaults.json when absent/null/empty. Claude is the resolveRuntime()
fallback so it needs no write; an explicit pre-existing runtime value is
always preserved across installs of any runtime.

Pattern parity with #1156 (default-to-omit intent) and #1569 (preserve
explicit user value) — the new write is the third sibling on the same
install-time persistence block.

Regression tests in tests/install.test.cjs cover:
  - absent / null / empty-string runtime → populated to <install runtime>
  - explicit pre-existing runtime preserved (no clobber across runtimes)
  - parameterized across 5 non-Claude runtimes (cursor, codex, opencode,
    antigravity, windsurf)

Out of scope (per triage): the resolveRuntime() precedence order itself
(env > config > default) is unchanged. A separate follow-up noted in the
issue (subagent rendering when runtime correctly identifies as 'cursor')
is unrelated to branding and not addressed here.

* chore(#2395): regenerate golden-install-parity fixtures for new runtime persistence

The golden install-tree fixtures capture the post-install state, including
~/.gsd/defaults.json. For non-Claude runtimes, defaults.json now includes
runtime: <runtime> — content hash updated for each affected runtime (17
golden-install-parity/*.json files). Claude's defaults.json is unchanged
(no runtime key written for Claude — it's the resolveRuntime() fallback).

* chore(#2395): drop product names from changeset fragment (product-name-purity gate)

* test(#2395): move describe out of fix-1521 fold + add same-runtime idempotence test

Code review (correctness subagent) flagged 2 Low test-quality issues:

1. The new Bug #2395 describe was inserted inside the
   folded:fix-1521-real-install-stamping IIFE callback, muddying test
   reporting and tracing the regression to the wrong epic. Moved it
   outside the IIFE close to be a top-level sibling.

2. No explicit same-runtime idempotence test — the suite covered
   cross-runtime preservation (cursor seed → opencode install) but not
   'install cursor twice → second is a no-op'. Added: seeds fresh defaults,
   installs cursor, captures mtime, installs cursor again, asserts runtime
   unchanged AND defaults.json mtime unchanged (idempotent, no rewrite churn).

* chore(changeset): backfill pr:2446 in .changeset/fierce-ravens-dance.md
This commit is contained in:
Tom Boucher
2026-07-20 07:57:04 -04:00
committed by GitHub
parent 12e4d93b19
commit e227e81f59
19 changed files with 161 additions and 16 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2446
---
**Non-Claude installs no longer brand all GSD output as Claude** — the installer never persisted `runtime: <id>` into `~/.gsd/defaults.json` for non-Claude runtimes, so `resolveRuntime()` (precedence: `GSD_RUNTIME` env > `config.runtime` > `'claude'`) fell through to the hard-coded `'claude'` default. A non-Claude install showed `agent_runtime: "claude"` and Claude-formatted `/gsd-*` slash hints with no env or config hand-set. The installer now persists `runtime: <runtime>` into `~/.gsd/defaults.json` for non-Claude runtimes, mirroring the existing `resolve_model_ids: "omit"` write at the same call site. Claude is the fallback so it needs no write; an explicit pre-existing `runtime` value is always preserved. (#2395)

View File

@@ -12266,6 +12266,19 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
console.log(` ${green}✓${reset} Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
}
// #2395: also persist `runtime: <runtime>` for non-Claude runtimes, so
// resolveRuntime() (precedence: GSD_RUNTIME env > config.runtime > 'claude')
// resolves to the install's actual runtime identity out of the box — without
// this, agent_runtime and every runtime-branded slash hint falls through to
// the hard-coded 'claude' default. Mirrors the resolve_model_ids write above:
// honor an explicit pre-existing value (any string), only default-populating
// when absent. Claude is the resolveRuntime() fallback, so it needs no write.
if (defaults.runtime === undefined || defaults.runtime === null || defaults.runtime === '') {
defaults.runtime = runtime;
fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
console.log(` ${green}✓${reset} Set runtime: "${runtime}" in ~/.gsd/defaults.json`);
}
} catch (e) {
console.log(` ${yellow}⚠${reset} Could not write ~/.gsd/defaults.json: ${e.message}`);
}

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "3f87862c1f6b7d30",
"agents/gsd-advisor-researcher.md": "45af60c237748ee1",
"agents/gsd-ai-researcher.md": "ba8898d9e739a501",
"agents/gsd-assumptions-analyzer.md": "00a02fdcbaa8d833",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "de00bae0761140d7",
"agents/gsd-advisor-researcher.md": "e5be2680b690c198",
"agents/gsd-ai-researcher.md": "f3baf9025a5bc4b1",
"agents/gsd-assumptions-analyzer.md": "b3219da6b194fdef",

View File

@@ -3,7 +3,7 @@
".clinerules/gsd.md": "d72c089c31c1fef3",
".clinerules/hooks/PreToolUse": "7271c83ab8a80911",
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "267b26d00031ac1f",
"agents/gsd-advisor-researcher.md": "e5be2680b690c198",
"agents/gsd-ai-researcher.md": "49ebcbe6985b86c9",
"agents/gsd-assumptions-analyzer.md": "b3219da6b194fdef",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "3abafed57651459c",
"agents/gsd-advisor-researcher.md": "e5be2680b690c198",
"agents/gsd-ai-researcher.md": "93ca6e24f4fd3681",
"agents/gsd-assumptions-analyzer.md": "b3219da6b194fdef",

View File

@@ -71,7 +71,7 @@
".agents/skills/gsd-workspace/SKILL.md": "06d6400d68318361",
".agents/skills/gsd-workstreams/SKILL.md": "2f77bb94db1be1a4",
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "5ca58f5ff7ef0b92",
"agents/gsd-advisor-researcher.md": "eea6d1604aaf305c",
"agents/gsd-advisor-researcher.toml": "144f037c79aa9c26",
"agents/gsd-ai-researcher.md": "303be751c91da4c8",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "56eda2d6c407679d",
"agents/gsd-advisor-researcher.agent.md": "1fb10242ff7fb1c9",
"agents/gsd-ai-researcher.agent.md": "8e9c3a2ddd179bb1",
"agents/gsd-assumptions-analyzer.agent.md": "cdabed4777a842fe",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "40c874a6a3dd0469",
"agents/gsd-advisor-researcher.md": "e5be2680b690c198",
"agents/gsd-ai-researcher.md": "abfc97706f16aaaf",
"agents/gsd-assumptions-analyzer.md": "b3219da6b194fdef",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "c4a285ff34429381",
"agents/gsd-advisor-researcher.md": "f1c4a47d3d8cea72",
"agents/gsd-ai-researcher.md": "08847faf5494ff08",
"agents/gsd-assumptions-analyzer.md": "f11a80795163bb5d",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "5a791a3c4ae9eb23",
"agents/gsd-advisor-researcher.md": "3296fc4edef47f2e",
"agents/gsd-ai-researcher.md": "9607993fbaafe9ba",
"agents/gsd-assumptions-analyzer.md": "567d823ccd036b39",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "fea73a3ad95e3454",
".kimi/hooks/gsd-check-update-worker.js": "6593f80914f4edee",
".kimi/hooks/gsd-check-update.js": "aac7612ee04ff5fe",
".kimi/hooks/gsd-config-reload.js": "96546e0e8bb47904",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "22e30e82b5758469",
"agents/gsd-advisor-researcher.md": "6955bcd8ca353de0",
"agents/gsd-ai-researcher.md": "28a05307dad84020",
"agents/gsd-assumptions-analyzer.md": "45c01017e84ba193",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "615261cfd3ae1c96",
"extensions/gsd.cjs": "619cec0af9cfdadf",
"gsd-core/.gsd-runtime": "94e95f0bb38f8e0f",
"gsd-core/VERSION": "ef0deccd81a6723c",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "91b2c6fa9472b9bb",
"agents/gsd-advisor-researcher.md": "692fbf5ed5e233d5",
"agents/gsd-ai-researcher.md": "f32dc6c6ed7e89fd",
"agents/gsd-assumptions-analyzer.md": "6a2e38d685ba0fce",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "57cf028821734f24",
"agents/gsd-advisor-researcher.md": "e5be2680b690c198",
"agents/gsd-ai-researcher.md": "abfc97706f16aaaf",
"agents/gsd-assumptions-analyzer.md": "b3219da6b194fdef",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "7a17effa731d8356",
"agents/gsd-advisor-researcher.md": "e5be2680b690c198",
"agents/gsd-ai-researcher.md": "abfc97706f16aaaf",
"agents/gsd-assumptions-analyzer.md": "b3219da6b194fdef",

View File

@@ -1,6 +1,6 @@
{
".gsd-profile": "0e716a5fef4e6dc1",
".gsd/defaults.json": "560664b045e645cb",
".gsd/defaults.json": "98c870b9b696a3a5",
"agents/gsd-advisor-researcher.md": "f1c4a47d3d8cea72",
"agents/gsd-ai-researcher.md": "2f2da4b89d445860",
"agents/gsd-assumptions-analyzer.md": "f11a80795163bb5d",

View File

@@ -10390,5 +10390,132 @@ test('real install: claude-emitted execute-phase.md keeps claude default + workt
'claude install must NOT have use_worktrees=false stamped',
);
});
});
}
// Bug #2395 — finishInstall for non-Claude runtimes never persisted `runtime: <id>`
// into ~/.gsd/defaults.json. resolveRuntime() precedence is GSD_RUNTIME > config.runtime
// > 'claude', so both inputs being empty on a Cursor (or any non-Claude) install caused
// every runtime-branded output (agent_runtime, formatGsdSlash hints, etc.) to silently
// fall through to 'claude'. Fix mirrors the resolve_model_ids: "omit" write site that
// already exists for non-Claude runtimes — writes runtime: <runtime> when absent.
describe('Bug #2395: finishInstall persists runtime identity for non-Claude runtimes', () => {
const LOCAL_ROOT = path.join(__dirname, '..');
const LOCAL_FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2395-test-'));
const LOCAL_GSD_DIR = path.join(LOCAL_FAKE_HOME, '.gsd');
const LOCAL_DEFAULTS_PATH = path.join(LOCAL_GSD_DIR, 'defaults.json');
const LOCAL_INSTALL_MODULE = require(path.join(LOCAL_ROOT, 'bin', 'install.js'));
const LOCAL_SETTINGS_PATH = path.join(LOCAL_FAKE_HOME, `gsd-test-settings-${process.pid}-2395.json`);
const { before: __before, after: __after } = require('node:test');
const __savedHome = process.env.HOME;
const __savedUserProfile = process.env.USERPROFILE;
__before(() => {
process.env.HOME = LOCAL_FAKE_HOME;
process.env.USERPROFILE = LOCAL_FAKE_HOME;
});
__after(() => {
if (__savedHome === undefined) delete process.env.HOME;
else process.env.HOME = __savedHome;
if (__savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = __savedUserProfile;
try { cleanup(LOCAL_FAKE_HOME); } catch { /* best-effort */ }
});
function callFinishInstall(runtime) {
const original = console.log;
console.log = () => {};
try {
LOCAL_INSTALL_MODULE.finishInstall(
LOCAL_SETTINGS_PATH,
{}, null, false, runtime, true, null,
);
} finally {
console.log = original;
}
}
function seedDefaults(obj) {
fs.mkdirSync(LOCAL_GSD_DIR, { recursive: true });
fs.writeFileSync(LOCAL_DEFAULTS_PATH, JSON.stringify(obj, null, 2) + '\n', 'utf8');
}
function withUserPath(fn) {
const saved = process.env.GSD_TEST_MODE;
delete process.env.GSD_TEST_MODE;
try {
return fn();
} finally {
process.env.GSD_TEST_MODE = saved;
}
}
test('absent runtime → written to <install runtime> (cursor install surfaces cursor branding)', () => {
withUserPath(() => {
seedDefaults({ model_profile: 'balanced' });
callFinishInstall('cursor');
const after = JSON.parse(fs.readFileSync(LOCAL_DEFAULTS_PATH, 'utf8'));
assert.equal(after.runtime, 'cursor', 'absent runtime must be populated with the install runtime identity');
});
});
test('null runtime → written to <install runtime> (treated as absent)', () => {
withUserPath(() => {
seedDefaults({ runtime: null, model_profile: 'balanced' });
callFinishInstall('cursor');
const after = JSON.parse(fs.readFileSync(LOCAL_DEFAULTS_PATH, 'utf8'));
assert.equal(after.runtime, 'cursor', 'null runtime must be treated as absent and populated');
});
});
test('empty-string runtime → written to <install runtime> (treated as absent)', () => {
withUserPath(() => {
seedDefaults({ runtime: '', model_profile: 'balanced' });
callFinishInstall('codex');
const after = JSON.parse(fs.readFileSync(LOCAL_DEFAULTS_PATH, 'utf8'));
assert.equal(after.runtime, 'codex', 'empty-string runtime must be treated as absent and populated');
});
});
test('explicit pre-existing runtime is preserved (no clobber across runtimes)', () => {
withUserPath(() => {
// User explicitly set runtime: 'cursor' — a subsequent opencode install must not clobber.
seedDefaults({ runtime: 'cursor', resolve_model_ids: 'omit' });
callFinishInstall('opencode');
const after = JSON.parse(fs.readFileSync(LOCAL_DEFAULTS_PATH, 'utf8'));
assert.equal(after.runtime, 'cursor', 'explicit user-set runtime must be preserved across install of a different runtime');
});
});
// The clobber guard is runtime-agnostic; parameterize across a representative slice of
// non-Claude runtimes. Each should populate its own runtime identity when absent.
for (const runtime of ['cursor', 'codex', 'opencode', 'antigravity', 'windsurf']) {
test(`${runtime} install populates runtime: "${runtime}" when absent`, () => {
withUserPath(() => {
seedDefaults({ model_profile: 'balanced' });
callFinishInstall(runtime);
const after = JSON.parse(fs.readFileSync(LOCAL_DEFAULTS_PATH, 'utf8'));
assert.equal(after.runtime, runtime, `${runtime} install must persist runtime: "${runtime}" so resolveRuntime() does not fall through to 'claude'`);
});
});
}
test('same-runtime idempotence: second cursor install is a no-op (runtime already set)', () => {
withUserPath(() => {
seedDefaults({ model_profile: 'balanced' });
callFinishInstall('cursor');
const after1 = JSON.parse(fs.readFileSync(LOCAL_DEFAULTS_PATH, 'utf8'));
assert.equal(after1.runtime, 'cursor', 'first install must populate runtime: cursor');
// Capture mtime; wait briefly so a no-op vs rewrite is distinguishable.
const beforeMtime = fs.statSync(LOCAL_DEFAULTS_PATH).mtimeMs;
const start = Date.now();
while (Date.now() - start < 20) { /* spin briefly */ }
callFinishInstall('cursor'); // second install — should be a no-op on runtime
const after2 = JSON.parse(fs.readFileSync(LOCAL_DEFAULTS_PATH, 'utf8'));
const afterMtime = fs.statSync(LOCAL_DEFAULTS_PATH).mtimeMs;
assert.equal(after2.runtime, 'cursor', 'second install must leave runtime: cursor intact');
assert.equal(afterMtime, beforeMtime, 'second install must NOT rewrite defaults.json (runtime already set, idempotent)');
});
});
});