From e227e81f599c82732ddf7b4bd42f85b93a803abb Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 20 Jul 2026 07:57:04 -0400 Subject: [PATCH] fix(#2395): persist runtime identity into ~/.gsd/defaults.json for non-Claude installs (#2446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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: 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 - 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: — 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 --- .changeset/fierce-ravens-dance.md | 5 + bin/install.js | 13 ++ .../golden-install-parity/antigravity.json | 2 +- .../golden-install-parity/augment.json | 2 +- .../fixtures/golden-install-parity/cline.json | 2 +- .../golden-install-parity/codebuddy.json | 2 +- .../fixtures/golden-install-parity/codex.json | 2 +- .../golden-install-parity/copilot.json | 2 +- .../golden-install-parity/cursor.json | 2 +- .../golden-install-parity/hermes.json | 2 +- .../fixtures/golden-install-parity/kilo.json | 2 +- .../fixtures/golden-install-parity/kimi.json | 2 +- .../golden-install-parity/opencode.json | 2 +- tests/fixtures/golden-install-parity/pi.json | 2 +- .../fixtures/golden-install-parity/qwen.json | 2 +- .../fixtures/golden-install-parity/trae.json | 2 +- .../golden-install-parity/windsurf.json | 2 +- .../fixtures/golden-install-parity/zcode.json | 2 +- tests/install.test.cjs | 127 ++++++++++++++++++ 19 files changed, 161 insertions(+), 16 deletions(-) create mode 100644 .changeset/fierce-ravens-dance.md diff --git a/.changeset/fierce-ravens-dance.md b/.changeset/fierce-ravens-dance.md new file mode 100644 index 000000000..71cca0b6a --- /dev/null +++ b/.changeset/fierce-ravens-dance.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2446 +--- +**Non-Claude installs no longer brand all GSD output as Claude** — the installer never persisted `runtime: ` 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: ` 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) diff --git a/bin/install.js b/bin/install.js index 0ad156757..d219cefce 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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: ` 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}`); } diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index deef91ac9..8badb9612 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index afd7ff427..b6c8648a8 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index 0792ab037..3b90a86f1 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 763bd989e..b90173eb8 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 0cbe08530..e953d7a75 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index e56507f99..c225df617 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 12198837f..3bb2f79b8 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index f93b37645..3c0d8ed1e 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 95fcc14c6..4a9110d09 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 0bf7a9d27..b6d4ed5a2 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 364129c7d..014691501 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/pi.json b/tests/fixtures/golden-install-parity/pi.json index c35b481cf..da735db09 100644 --- a/tests/fixtures/golden-install-parity/pi.json +++ b/tests/fixtures/golden-install-parity/pi.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 2c071c038..2f1a80044 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index f2e165dc1..cf07f4569 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index e4cc3f06b..d6e6de903 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index 4f9ff39c2..c86d5a7c0 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -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", diff --git a/tests/install.test.cjs b/tests/install.test.cjs index d009dd0ae..83127341b 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -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: ` +// 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: 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 (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 (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 (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)'); + }); + }); +});