From ee6f3b70c580fe4b73a4b4cf965a0d5407634d81 Mon Sep 17 00:00:00 2001 From: Joe Slitzker Date: Sat, 20 Jun 2026 09:15:59 -0500 Subject: [PATCH 1/8] fix(#1477): write .gsd-source marker at install; resolve install-exports for the deployed layout The Claude Code global skills layout ships gsd-core/{bin,contexts,references, templates,workflows} but not the commands/gsd source tree, and _runLegacyUninstallCleanup removes any commands/gsd/ for that scope. So at runtime findInstallSourceRoot's walk-up from gsd-core/bin/lib has nothing to find and /gsd-surface throws for every subcommand (list/status included). The marker reader added in #1476 never fired because nothing wrote the marker. Writer half (Failure 1): install() writes /.gsd-source pointing at the package's commands/gsd source (guarded on its presence so a half-published package never leaves a dangling marker). findInstallSourceRoot already prefers this marker over the walk-up. Deployed install-exports (Failure 2): loadInstallExports' relative '../../../bin/install.js' only resolves in the repo; in a deployed tree it points at /bin/install.js, which is never shipped, so the surface write subcommands threw MODULE_NOT_FOUND. Derive install.js from the resolved commands/gsd source root instead (its package root holds both commands/gsd and bin/install.js), which is correct in the repo (walk-up) and in deployed installs (marker) alike. No file relocation needed, so install.js' own internal requires keep resolving. applySurface threads layout.configDir so the marker is honored. Regression test reproduces the deployed global layout end-to-end: without the marker the deployed walk-up throws, and getInstallExports under the old relative path throws MODULE_NOT_FOUND; with both fixes list/status resolve and the install-exports load. Adversarial marker cases (dangling, empty/whitespace) fall through to walk-up. Claude-Session: https://claude.ai/code/session_01XNT3SWgzjmEycNuweURDme --- bin/install.js | 18 ++ src/runtime-artifact-layout.cts | 33 ++- src/surface.cts | 4 +- tests/bug-1477-surface-source-marker.test.cjs | 210 ++++++++++++++++++ 4 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 tests/bug-1477-surface-source-marker.test.cjs diff --git a/bin/install.js b/bin/install.js index 8b982f426..51987bdf6 100755 --- a/bin/install.js +++ b/bin/install.js @@ -10026,6 +10026,24 @@ function install(isGlobal, runtime = 'claude', options = {}) { failures.push('gsd-core'); } + // Write the .gsd-source marker so runtime source resolution succeeds at + // runtime (#1477). The Claude-global skills layout ships gsd-core/{bin, + // contexts,references,templates,workflows} but NOT the commands/gsd source + // tree, and _runLegacyUninstallCleanup actively removes any commands/gsd/ + // for that scope — so findInstallSourceRoot's walk-up has nothing to find + // and /gsd-surface (list/status and the write subcommands) throws. This is + // the writer half of the marker that runtime-artifact-layout.cjs's finders + // already read (the reader landed in #1476). It points at the package's own + // commands/gsd source, whose parent also holds bin/install.js — the path + // loadInstallExports derives the installer exports from. Guarded on source + // presence so a half-published package never writes a dangling marker. + const gsdSourceCommands = path.join(src, 'commands', 'gsd'); + if (fs.existsSync(gsdSourceCommands)) { + try { + fs.writeFileSync(path.join(targetDir, '.gsd-source'), gsdSourceCommands + '\n', 'utf8'); + } catch (_) { /* non-fatal: surface degrades to walk-up resolution */ } + } + // Copy shared manifests into the gsd-core payload // at the co-located path that CJS modules resolve first: // gsd-core/bin/shared/*.json diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 6791af886..a49738afd 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -44,17 +44,42 @@ interface InstallExports { [converterName: string]: unknown; } +/** + * Resolve the absolute path to bin/install.js for the current layout (#1477). + * + * The relative specifier '../../../bin/install.js' only resolves in the repo + * (where this module lives at gsd-core/bin/lib/). In a deployed install the + * module sits at /gsd-core/bin/lib/ and that specifier points at + * /bin/install.js, which is never shipped — so the surface write + * subcommands threw MODULE_NOT_FOUND. Instead, derive install.js from the + * resolved commands/gsd source root: its parent package root holds both + * commands/gsd and bin/install.js. findInstallSourceRoot honors the + * /.gsd-source marker (deployed) and walks up to the repo root + * (repo/tests), so this single derivation is correct in both layouts. Falls + * back to the legacy relative path if no source root can be resolved. + */ +function resolveInstallJsPath(runtimeConfigDir?: string): string { + try { + const commandsGsd = findInstallSourceRoot(runtimeConfigDir); + // /commands/gsd -> /bin/install.js + const candidate = path.resolve(commandsGsd, '..', '..', 'bin', 'install.js'); + if (fs.existsSync(candidate)) return candidate; + } catch { /* fall through to the legacy module-relative path */ } + return path.join(__dirname, '..', '..', '..', 'bin', 'install.js'); +} + /** * Load bin/install.js exports in a test-safe way. * Sets GSD_TEST_MODE only for the duration of the require() call and only if * it was not already set, restoring the original value in a finally block so * the module-level environment is never permanently mutated. */ -function loadInstallExports(): InstallExports { +function loadInstallExports(runtimeConfigDir?: string): InstallExports { + const installPath = resolveInstallJsPath(runtimeConfigDir); const savedTestMode = process.env['GSD_TEST_MODE']; if (savedTestMode === undefined) process.env['GSD_TEST_MODE'] = '1'; try { - return _require('../../../bin/install.js') as InstallExports; + return _require(installPath) as InstallExports; } finally { if (savedTestMode === undefined) delete process.env['GSD_TEST_MODE']; else process.env['GSD_TEST_MODE'] = savedTestMode; @@ -63,8 +88,8 @@ function loadInstallExports(): InstallExports { /** Cache after first successful load. */ let _installExports: InstallExports | null = null; -function getInstallExports(): InstallExports { - if (!_installExports) _installExports = loadInstallExports(); +function getInstallExports(runtimeConfigDir?: string): InstallExports { + if (!_installExports) _installExports = loadInstallExports(runtimeConfigDir); return _installExports; } diff --git a/src/surface.cts b/src/surface.cts index bbd04141d..c557c58dd 100644 --- a/src/surface.cts +++ b/src/surface.cts @@ -311,7 +311,9 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map/.gsd-source marker in deployed installs (#1477). + const installExports = getInstallExports(layout.configDir); if (pathPrefix === null) { const scope = layout.scope ?? 'global'; const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/'); diff --git a/tests/bug-1477-surface-source-marker.test.cjs b/tests/bug-1477-surface-source-marker.test.cjs new file mode 100644 index 000000000..e0d594f42 --- /dev/null +++ b/tests/bug-1477-surface-source-marker.test.cjs @@ -0,0 +1,210 @@ +/** + * Regression test for #1477: Claude Code global install ships no commands/gsd + * source and never writes the .gsd-source marker, so /gsd-surface is fully + * non-functional (list/status throw at findInstallSourceRoot, and the write + * subcommands throw MODULE_NOT_FOUND at loadInstallExports). + * + * Repro (deployed Claude global layout): + * ~/.claude/gsd-core/{bin,contexts,references,templates,workflows} — no commands/gsd + * ~/.claude/.gsd-source — never written + * ~/.claude/bin/install.js — never shipped + * + * findInstallSourceRoot walks up from gsd-core/bin/lib looking for + * commands/gsd, finds nothing, and throws — killing list/status. The marker + * step that PR #1476 added (read side) never fires because nothing writes the + * marker (this issue is the write side). loadInstallExports' relative + * '../../../bin/install.js' resolves to ~/.claude/bin/install.js, which does + * not exist — killing profile/disable/enable/reset. + * + * Fix contract (both halves required): + * 1. bin/install.js writes /.gsd-source pointing at a resolvable + * commands/gsd source whose package root also holds bin/install.js. + * 2. runtime-artifact-layout.cjs derives bin/install.js from that resolved + * source root, so install-exports load in the deployed layout too. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const REPO_ROOT = path.join(__dirname, '..'); +const { install } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// The repo-resident module — exercised directly for the adversarial marker-reader +// cases (its walk-up always finds the repo commands/gsd, so marker precedence and +// fall-through can be asserted without a full install). +const { + findInstallSourceRoot, +} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + +function silenceConsole(fn) { + const orig = { log: console.log, warn: console.warn, error: console.error }; + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + try { + return fn(); + } finally { + console.log = orig.log; + console.warn = orig.warn; + console.error = orig.error; + } +} + +describe('bug #1477: .gsd-source marker provisioning + deployed install-exports resolution', () => { + let tmpRoot; + let savedHome; + let savedUserProfile; + let savedExplicitConfigDir; + + beforeEach(() => { + tmpRoot = createTempDir('gsd-1477-'); + savedHome = process.env.HOME; + // os.homedir() reads USERPROFILE on win32, HOME elsewhere; redirect both so + // install() targets the fixture regardless of platform. + savedUserProfile = process.env.USERPROFILE; + process.env.HOME = tmpRoot; + process.env.USERPROFILE = tmpRoot; + savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; + delete process.env.GSD_EXPLICIT_CONFIG_DIR; + }); + + afterEach(() => { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + if (savedExplicitConfigDir === undefined) delete process.env.GSD_EXPLICIT_CONFIG_DIR; + else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; + cleanup(tmpRoot); + }); + + // Guard against process.exit killing the runner mid-install. + function runInstall(isGlobal, runtime) { + const origExit = process.exit; + let exitCalled = false; + process.exit = (code) => { + exitCalled = true; + throw new Error(`process.exit(${code}) during install — should not happen`); + }; + try { + return silenceConsole(() => install(isGlobal, runtime)); + } catch (e) { + if (exitCalled) assert.fail(`install() called process.exit — unexpected: ${e.message}`); + throw e; + } finally { + process.exit = origExit; + } + } + + // ── Failure 1: the installer provisions a valid marker ────────────────────── + test('global claude install writes a .gsd-source marker pointing at a real commands/gsd', () => { + const claudeDir = path.join(tmpRoot, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + + runInstall(true /* isGlobal */, 'claude'); + + const markerPath = path.join(claudeDir, '.gsd-source'); + assert.ok(fs.existsSync(markerPath), `.gsd-source marker must be written at ${markerPath}`); + + const markerSrc = fs.readFileSync(markerPath, 'utf8').trim(); + assert.ok(path.isAbsolute(markerSrc), `marker must contain an absolute path, got: ${markerSrc}`); + assert.ok(fs.existsSync(markerSrc), `marker target must exist on disk: ${markerSrc}`); + assert.equal( + path.basename(markerSrc), 'gsd', + 'marker must point at a commands/gsd directory', + ); + assert.equal(path.basename(path.dirname(markerSrc)), 'commands'); + + // The package root that holds commands/gsd must also hold bin/install.js — + // this is what loadInstallExports derives the installer exports from. + const derivedInstallJs = path.resolve(markerSrc, '..', '..', 'bin', 'install.js'); + assert.ok( + fs.existsSync(derivedInstallJs), + `bin/install.js must be reachable from the marker's package root: ${derivedInstallJs}`, + ); + }); + + // ── Failures 1+2 end-to-end: resolution succeeds FROM the deployed tree ────── + // The deployed module's __dirname is /gsd-core/bin/lib, which has no + // commands/gsd ancestor (global skills layout). Only the marker rescues it. + test('deployed global layout resolves source root + install-exports via the marker', () => { + const claudeDir = path.join(tmpRoot, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + runInstall(true /* isGlobal */, 'claude'); + + // Sanity: the global layout genuinely ships no commands/gsd source tree. + assert.ok( + !fs.existsSync(path.join(claudeDir, 'commands', 'gsd')), + 'precondition: global claude install must not ship commands/gsd', + ); + + const deployedLayoutPath = path.join(claudeDir, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs'); + assert.ok(fs.existsSync(deployedLayoutPath), 'deployed runtime-artifact-layout.cjs must exist'); + delete require.cache[deployedLayoutPath]; + const deployed = require(deployedLayoutPath); + + // Negative proof that the bug condition exists: WITHOUT consulting the marker + // (no configDir argument), walk-up from the deployed tree has nothing to find. + assert.throws( + () => deployed.findInstallSourceRoot(), + /could not locate commands\/gsd/, + 'deployed walk-up must fail without the marker — this is the regression condition', + ); + + // With the marker (configDir provided), list/status resolution succeeds. + let resolved; + assert.doesNotThrow(() => { + resolved = deployed.findInstallSourceRoot(claudeDir); + }, 'findInstallSourceRoot must resolve via the .gsd-source marker'); + assert.equal(path.basename(resolved), 'gsd'); + assert.ok(fs.existsSync(resolved)); + + // The write-subcommand path: install-exports must load in the deployed layout. + const exportsObj = deployed.getInstallExports(claudeDir); + assert.equal(typeof exportsObj.computePathPrefix, 'function', + 'getInstallExports must expose computePathPrefix (used by applySurface)'); + assert.equal(typeof exportsObj.applyRuntimeContentRewritesInPlace, 'function', + 'getInstallExports must expose applyRuntimeContentRewritesInPlace (used by applySurface)'); + }); + + // ── Adversarial marker-reader cases (no full install needed) ───────────────── + describe('findInstallSourceRoot marker handling', () => { + let cfgDir; + beforeEach(() => { cfgDir = createTempDir('gsd-1477-marker-'); }); + afterEach(() => { cleanup(cfgDir); }); + + test('marker pointing at a valid commands/gsd takes precedence over walk-up', () => { + const fakeSrc = path.join(cfgDir, 'pkg', 'commands', 'gsd'); + fs.mkdirSync(fakeSrc, { recursive: true }); + fs.writeFileSync(path.join(cfgDir, '.gsd-source'), fakeSrc + '\n', 'utf8'); + + const resolved = findInstallSourceRoot(cfgDir); + assert.equal(path.resolve(resolved), path.resolve(fakeSrc), + 'marker target must win over the repo walk-up'); + }); + + test('marker pointing at a non-existent path is ignored (falls through to walk-up)', () => { + const ghost = path.join(cfgDir, 'does', 'not', 'exist', 'commands', 'gsd'); + fs.writeFileSync(path.join(cfgDir, '.gsd-source'), ghost + '\n', 'utf8'); + + // In-repo walk-up still resolves the real commands/gsd — no throw, and it is + // NOT the dangling marker target. + const resolved = findInstallSourceRoot(cfgDir); + assert.notEqual(path.resolve(resolved), path.resolve(ghost)); + assert.equal(path.resolve(resolved), path.resolve(REPO_ROOT, 'commands', 'gsd')); + }); + + test('empty / whitespace-only marker is ignored', () => { + fs.writeFileSync(path.join(cfgDir, '.gsd-source'), ' \n', 'utf8'); + const resolved = findInstallSourceRoot(cfgDir); + assert.equal(path.resolve(resolved), path.resolve(REPO_ROOT, 'commands', 'gsd')); + }); + }); +}); From e574ad6fdfcbcf47fc8257ac55a3fa154709d38e Mon Sep 17 00:00:00 2001 From: Joe Slitzker Date: Sat, 20 Jun 2026 09:28:18 -0500 Subject: [PATCH 2/8] chore(#1477): add changeset fragment for surface source-marker fix Claude-Session: https://claude.ai/code/session_01XNT3SWgzjmEycNuweURDme --- .changeset/brave-hawks-fly.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/brave-hawks-fly.md diff --git a/.changeset/brave-hawks-fly.md b/.changeset/brave-hawks-fly.md new file mode 100644 index 000000000..c87926d19 --- /dev/null +++ b/.changeset/brave-hawks-fly.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1487 +--- +**`/gsd-surface` works on Claude Code global installs** — the installer now writes a `.gsd-source` marker and install-exports resolve in the deployed layout, so `list`/`status` and `profile`/`enable`/`disable`/`reset` no longer throw `could not locate commands/gsd` or `MODULE_NOT_FOUND`. From a9c30841ce95897b6f360b6794ab3b38bf357536 Mon Sep 17 00:00:00 2001 From: Joe Slitzker Date: Sat, 20 Jun 2026 09:38:18 -0500 Subject: [PATCH 3/8] test(#1477): move regression cases into the owning module test file The lint-regression-test-names gate rejects new bug-NNNN-*.test.cjs files: a new regression case must live in its owning module's test file. Relocate the #1477 cases into tests/runtime-artifact-layout.test.cjs (the home of the findInstallSourceRoot / getInstallExports resolution seam at the heart of the bug) as a dedicated describe block, and delete the standalone file. Claude-Session: https://claude.ai/code/session_01XNT3SWgzjmEycNuweURDme --- tests/bug-1477-surface-source-marker.test.cjs | 210 ------------------ tests/runtime-artifact-layout.test.cjs | 192 +++++++++++++++- 2 files changed, 189 insertions(+), 213 deletions(-) delete mode 100644 tests/bug-1477-surface-source-marker.test.cjs diff --git a/tests/bug-1477-surface-source-marker.test.cjs b/tests/bug-1477-surface-source-marker.test.cjs deleted file mode 100644 index e0d594f42..000000000 --- a/tests/bug-1477-surface-source-marker.test.cjs +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Regression test for #1477: Claude Code global install ships no commands/gsd - * source and never writes the .gsd-source marker, so /gsd-surface is fully - * non-functional (list/status throw at findInstallSourceRoot, and the write - * subcommands throw MODULE_NOT_FOUND at loadInstallExports). - * - * Repro (deployed Claude global layout): - * ~/.claude/gsd-core/{bin,contexts,references,templates,workflows} — no commands/gsd - * ~/.claude/.gsd-source — never written - * ~/.claude/bin/install.js — never shipped - * - * findInstallSourceRoot walks up from gsd-core/bin/lib looking for - * commands/gsd, finds nothing, and throws — killing list/status. The marker - * step that PR #1476 added (read side) never fires because nothing writes the - * marker (this issue is the write side). loadInstallExports' relative - * '../../../bin/install.js' resolves to ~/.claude/bin/install.js, which does - * not exist — killing profile/disable/enable/reset. - * - * Fix contract (both halves required): - * 1. bin/install.js writes /.gsd-source pointing at a resolvable - * commands/gsd source whose package root also holds bin/install.js. - * 2. runtime-artifact-layout.cjs derives bin/install.js from that resolved - * source root, so install-exports load in the deployed layout too. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const REPO_ROOT = path.join(__dirname, '..'); -const { install } = require('../bin/install.js'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -// The repo-resident module — exercised directly for the adversarial marker-reader -// cases (its walk-up always finds the repo commands/gsd, so marker precedence and -// fall-through can be asserted without a full install). -const { - findInstallSourceRoot, -} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - -function silenceConsole(fn) { - const orig = { log: console.log, warn: console.warn, error: console.error }; - console.log = () => {}; - console.warn = () => {}; - console.error = () => {}; - try { - return fn(); - } finally { - console.log = orig.log; - console.warn = orig.warn; - console.error = orig.error; - } -} - -describe('bug #1477: .gsd-source marker provisioning + deployed install-exports resolution', () => { - let tmpRoot; - let savedHome; - let savedUserProfile; - let savedExplicitConfigDir; - - beforeEach(() => { - tmpRoot = createTempDir('gsd-1477-'); - savedHome = process.env.HOME; - // os.homedir() reads USERPROFILE on win32, HOME elsewhere; redirect both so - // install() targets the fixture regardless of platform. - savedUserProfile = process.env.USERPROFILE; - process.env.HOME = tmpRoot; - process.env.USERPROFILE = tmpRoot; - savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; - delete process.env.GSD_EXPLICIT_CONFIG_DIR; - }); - - afterEach(() => { - if (savedHome === undefined) delete process.env.HOME; - else process.env.HOME = savedHome; - if (savedUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = savedUserProfile; - if (savedExplicitConfigDir === undefined) delete process.env.GSD_EXPLICIT_CONFIG_DIR; - else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; - cleanup(tmpRoot); - }); - - // Guard against process.exit killing the runner mid-install. - function runInstall(isGlobal, runtime) { - const origExit = process.exit; - let exitCalled = false; - process.exit = (code) => { - exitCalled = true; - throw new Error(`process.exit(${code}) during install — should not happen`); - }; - try { - return silenceConsole(() => install(isGlobal, runtime)); - } catch (e) { - if (exitCalled) assert.fail(`install() called process.exit — unexpected: ${e.message}`); - throw e; - } finally { - process.exit = origExit; - } - } - - // ── Failure 1: the installer provisions a valid marker ────────────────────── - test('global claude install writes a .gsd-source marker pointing at a real commands/gsd', () => { - const claudeDir = path.join(tmpRoot, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - - runInstall(true /* isGlobal */, 'claude'); - - const markerPath = path.join(claudeDir, '.gsd-source'); - assert.ok(fs.existsSync(markerPath), `.gsd-source marker must be written at ${markerPath}`); - - const markerSrc = fs.readFileSync(markerPath, 'utf8').trim(); - assert.ok(path.isAbsolute(markerSrc), `marker must contain an absolute path, got: ${markerSrc}`); - assert.ok(fs.existsSync(markerSrc), `marker target must exist on disk: ${markerSrc}`); - assert.equal( - path.basename(markerSrc), 'gsd', - 'marker must point at a commands/gsd directory', - ); - assert.equal(path.basename(path.dirname(markerSrc)), 'commands'); - - // The package root that holds commands/gsd must also hold bin/install.js — - // this is what loadInstallExports derives the installer exports from. - const derivedInstallJs = path.resolve(markerSrc, '..', '..', 'bin', 'install.js'); - assert.ok( - fs.existsSync(derivedInstallJs), - `bin/install.js must be reachable from the marker's package root: ${derivedInstallJs}`, - ); - }); - - // ── Failures 1+2 end-to-end: resolution succeeds FROM the deployed tree ────── - // The deployed module's __dirname is /gsd-core/bin/lib, which has no - // commands/gsd ancestor (global skills layout). Only the marker rescues it. - test('deployed global layout resolves source root + install-exports via the marker', () => { - const claudeDir = path.join(tmpRoot, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - runInstall(true /* isGlobal */, 'claude'); - - // Sanity: the global layout genuinely ships no commands/gsd source tree. - assert.ok( - !fs.existsSync(path.join(claudeDir, 'commands', 'gsd')), - 'precondition: global claude install must not ship commands/gsd', - ); - - const deployedLayoutPath = path.join(claudeDir, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs'); - assert.ok(fs.existsSync(deployedLayoutPath), 'deployed runtime-artifact-layout.cjs must exist'); - delete require.cache[deployedLayoutPath]; - const deployed = require(deployedLayoutPath); - - // Negative proof that the bug condition exists: WITHOUT consulting the marker - // (no configDir argument), walk-up from the deployed tree has nothing to find. - assert.throws( - () => deployed.findInstallSourceRoot(), - /could not locate commands\/gsd/, - 'deployed walk-up must fail without the marker — this is the regression condition', - ); - - // With the marker (configDir provided), list/status resolution succeeds. - let resolved; - assert.doesNotThrow(() => { - resolved = deployed.findInstallSourceRoot(claudeDir); - }, 'findInstallSourceRoot must resolve via the .gsd-source marker'); - assert.equal(path.basename(resolved), 'gsd'); - assert.ok(fs.existsSync(resolved)); - - // The write-subcommand path: install-exports must load in the deployed layout. - const exportsObj = deployed.getInstallExports(claudeDir); - assert.equal(typeof exportsObj.computePathPrefix, 'function', - 'getInstallExports must expose computePathPrefix (used by applySurface)'); - assert.equal(typeof exportsObj.applyRuntimeContentRewritesInPlace, 'function', - 'getInstallExports must expose applyRuntimeContentRewritesInPlace (used by applySurface)'); - }); - - // ── Adversarial marker-reader cases (no full install needed) ───────────────── - describe('findInstallSourceRoot marker handling', () => { - let cfgDir; - beforeEach(() => { cfgDir = createTempDir('gsd-1477-marker-'); }); - afterEach(() => { cleanup(cfgDir); }); - - test('marker pointing at a valid commands/gsd takes precedence over walk-up', () => { - const fakeSrc = path.join(cfgDir, 'pkg', 'commands', 'gsd'); - fs.mkdirSync(fakeSrc, { recursive: true }); - fs.writeFileSync(path.join(cfgDir, '.gsd-source'), fakeSrc + '\n', 'utf8'); - - const resolved = findInstallSourceRoot(cfgDir); - assert.equal(path.resolve(resolved), path.resolve(fakeSrc), - 'marker target must win over the repo walk-up'); - }); - - test('marker pointing at a non-existent path is ignored (falls through to walk-up)', () => { - const ghost = path.join(cfgDir, 'does', 'not', 'exist', 'commands', 'gsd'); - fs.writeFileSync(path.join(cfgDir, '.gsd-source'), ghost + '\n', 'utf8'); - - // In-repo walk-up still resolves the real commands/gsd — no throw, and it is - // NOT the dangling marker target. - const resolved = findInstallSourceRoot(cfgDir); - assert.notEqual(path.resolve(resolved), path.resolve(ghost)); - assert.equal(path.resolve(resolved), path.resolve(REPO_ROOT, 'commands', 'gsd')); - }); - - test('empty / whitespace-only marker is ignored', () => { - fs.writeFileSync(path.join(cfgDir, '.gsd-source'), ' \n', 'utf8'); - const resolved = findInstallSourceRoot(cfgDir); - assert.equal(path.resolve(resolved), path.resolve(REPO_ROOT, 'commands', 'gsd')); - }); - }); -}); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 0df4ca2da..c3ba616e8 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -17,14 +17,17 @@ * runtime-artifact-layout-install-profiles.test.cjs — install-profiles seam */ -const { test, describe } = require('node:test'); +const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { resolveRuntimeArtifactLayout, findInstallSourceRoot } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); const installProfiles = require('../gsd-core/bin/lib/install-profiles.cjs'); -const { cleanup } = require('./helpers.cjs'); +const { install } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); const FAKE_DIR = '/tmp/fake-config-dir'; @@ -621,3 +624,186 @@ describe('stage — cursor commands kind (#785)', () => { } }); }); + +// ─── #1477: .gsd-source marker provisioning + deployed install-exports ───────── +// +// Regression for #1477: the Claude Code global skills layout ships +// gsd-core/{bin,contexts,references,templates,workflows} but no commands/gsd +// source tree, and _runLegacyUninstallCleanup removes any commands/gsd/ for that +// scope. At runtime findInstallSourceRoot's walk-up from gsd-core/bin/lib had +// nothing to find and /gsd-surface threw for every subcommand (list/status +// included); the marker reader added in #1476 never fired because nothing wrote +// the marker. loadInstallExports' relative '../../../bin/install.js' also only +// resolved in the repo — in a deployed tree it pointed at /bin/ +// install.js, which is never shipped, so the surface write subcommands threw +// MODULE_NOT_FOUND. +// +// Fix (both halves): bin/install.js writes /.gsd-source pointing at a +// resolvable commands/gsd whose package root also holds bin/install.js, and +// runtime-artifact-layout derives bin/install.js from that resolved source root. + +describe('#1477 .gsd-source marker provisioning + deployed install-exports resolution', () => { + let tmpRoot; + let savedHome; + let savedUserProfile; + let savedExplicitConfigDir; + let savedTestMode; + + function silenceConsole(fn) { + const orig = { log: console.log, warn: console.warn, error: console.error }; + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + try { + return fn(); + } finally { + console.log = orig.log; + console.warn = orig.warn; + console.error = orig.error; + } + } + + // Guard against process.exit killing the runner mid-install. + function runInstall(isGlobal, runtime) { + const origExit = process.exit; + let exitCalled = false; + process.exit = (code) => { + exitCalled = true; + throw new Error(`process.exit(${code}) during install — should not happen`); + }; + try { + return silenceConsole(() => install(isGlobal, runtime)); + } catch (e) { + if (exitCalled) assert.fail(`install() called process.exit — unexpected: ${e.message}`); + throw e; + } finally { + process.exit = origExit; + } + } + + beforeEach(() => { + tmpRoot = createTempDir('gsd-1477-'); + savedHome = process.env.HOME; + // os.homedir() reads USERPROFILE on win32, HOME elsewhere; redirect both so + // install() targets the fixture regardless of platform. + savedUserProfile = process.env.USERPROFILE; + process.env.HOME = tmpRoot; + process.env.USERPROFILE = tmpRoot; + savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; + delete process.env.GSD_EXPLICIT_CONFIG_DIR; + savedTestMode = process.env.GSD_TEST_MODE; + process.env.GSD_TEST_MODE = '1'; + }); + + afterEach(() => { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + if (savedExplicitConfigDir === undefined) delete process.env.GSD_EXPLICIT_CONFIG_DIR; + else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; + if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE; + else process.env.GSD_TEST_MODE = savedTestMode; + cleanup(tmpRoot); + }); + + // ── Failure 1: the installer provisions a valid marker ────────────────────── + test('global claude install writes a .gsd-source marker pointing at a real commands/gsd', () => { + const claudeDir = path.join(tmpRoot, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + + runInstall(true /* isGlobal */, 'claude'); + + const markerPath = path.join(claudeDir, '.gsd-source'); + assert.ok(fs.existsSync(markerPath), `.gsd-source marker must be written at ${markerPath}`); + + const markerSrc = fs.readFileSync(markerPath, 'utf8').trim(); + assert.ok(path.isAbsolute(markerSrc), `marker must contain an absolute path, got: ${markerSrc}`); + assert.ok(fs.existsSync(markerSrc), `marker target must exist on disk: ${markerSrc}`); + assert.equal(path.basename(markerSrc), 'gsd', 'marker must point at a commands/gsd directory'); + assert.equal(path.basename(path.dirname(markerSrc)), 'commands'); + + // The package root that holds commands/gsd must also hold bin/install.js — + // this is what loadInstallExports derives the installer exports from. + const derivedInstallJs = path.resolve(markerSrc, '..', '..', 'bin', 'install.js'); + assert.ok( + fs.existsSync(derivedInstallJs), + `bin/install.js must be reachable from the marker's package root: ${derivedInstallJs}`, + ); + }); + + // ── Failures 1+2 end-to-end: resolution succeeds FROM the deployed tree ────── + // The deployed module's __dirname is /gsd-core/bin/lib, which has no + // commands/gsd ancestor (global skills layout). Only the marker rescues it. + test('deployed global layout resolves source root + install-exports via the marker', () => { + const claudeDir = path.join(tmpRoot, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + runInstall(true /* isGlobal */, 'claude'); + + // Sanity: the global layout genuinely ships no commands/gsd source tree. + assert.ok( + !fs.existsSync(path.join(claudeDir, 'commands', 'gsd')), + 'precondition: global claude install must not ship commands/gsd', + ); + + const deployedLayoutPath = path.join(claudeDir, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs'); + assert.ok(fs.existsSync(deployedLayoutPath), 'deployed runtime-artifact-layout.cjs must exist'); + delete require.cache[deployedLayoutPath]; + const deployed = require(deployedLayoutPath); + + // Negative proof that the bug condition exists: WITHOUT consulting the marker + // (no configDir argument), walk-up from the deployed tree has nothing to find. + assert.throws( + () => deployed.findInstallSourceRoot(), + /could not locate commands\/gsd/, + 'deployed walk-up must fail without the marker — this is the regression condition', + ); + + // With the marker (configDir provided), list/status resolution succeeds. + let resolved; + assert.doesNotThrow(() => { + resolved = deployed.findInstallSourceRoot(claudeDir); + }, 'findInstallSourceRoot must resolve via the .gsd-source marker'); + assert.equal(path.basename(resolved), 'gsd'); + assert.ok(fs.existsSync(resolved)); + + // The write-subcommand path: install-exports must load in the deployed layout. + const exportsObj = deployed.getInstallExports(claudeDir); + assert.equal(typeof exportsObj.computePathPrefix, 'function', + 'getInstallExports must expose computePathPrefix (used by applySurface)'); + assert.equal(typeof exportsObj.applyRuntimeContentRewritesInPlace, 'function', + 'getInstallExports must expose applyRuntimeContentRewritesInPlace (used by applySurface)'); + }); + + // ── Adversarial marker-reader cases (no full install needed) ───────────────── + describe('findInstallSourceRoot marker handling', () => { + let cfgDir; + beforeEach(() => { cfgDir = createTempDir('gsd-1477-marker-'); }); + afterEach(() => { cleanup(cfgDir); }); + + test('marker pointing at a valid commands/gsd takes precedence over walk-up', () => { + const fakeSrc = path.join(cfgDir, 'pkg', 'commands', 'gsd'); + fs.mkdirSync(fakeSrc, { recursive: true }); + fs.writeFileSync(path.join(cfgDir, '.gsd-source'), fakeSrc + '\n', 'utf8'); + + const resolved = findInstallSourceRoot(cfgDir); + assert.equal(path.resolve(resolved), path.resolve(fakeSrc), + 'marker target must win over the repo walk-up'); + }); + + test('marker pointing at a non-existent path is ignored (falls through to walk-up)', () => { + const ghost = path.join(cfgDir, 'does', 'not', 'exist', 'commands', 'gsd'); + fs.writeFileSync(path.join(cfgDir, '.gsd-source'), ghost + '\n', 'utf8'); + + const resolved = findInstallSourceRoot(cfgDir); + assert.notEqual(path.resolve(resolved), path.resolve(ghost)); + assert.equal(path.resolve(resolved), path.resolve(REPO_ROOT, 'commands', 'gsd')); + }); + + test('empty / whitespace-only marker is ignored', () => { + fs.writeFileSync(path.join(cfgDir, '.gsd-source'), ' \n', 'utf8'); + const resolved = findInstallSourceRoot(cfgDir); + assert.equal(path.resolve(resolved), path.resolve(REPO_ROOT, 'commands', 'gsd')); + }); + }); +}); From cd47d9d31764a2a35dba8394f90e7b260519f81b Mon Sep 17 00:00:00 2001 From: Joe Slitzker Date: Sat, 20 Jun 2026 13:40:29 -0500 Subject: [PATCH 4/8] fix(#1477): scope .gsd-source marker write to claude global; add PR ref to changeset Address review on #1487: - Scope the marker write to runtime === 'claude' && isGlobal, matching issue #1477. The Claude global skills layout is the only install path that ships the skills layout without a commands/gsd source tree; every other runtime/scope deploys commands/gsd, so walk-up already resolves. - Add the trailing (#1487) reference to the changeset body per PRED.k329.body. Claude-Session: https://claude.ai/code/session_01XNT3SWgzjmEycNuweURDme --- .changeset/brave-hawks-fly.md | 2 +- bin/install.js | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.changeset/brave-hawks-fly.md b/.changeset/brave-hawks-fly.md index c87926d19..a2f75e972 100644 --- a/.changeset/brave-hawks-fly.md +++ b/.changeset/brave-hawks-fly.md @@ -2,4 +2,4 @@ type: Fixed pr: 1487 --- -**`/gsd-surface` works on Claude Code global installs** — the installer now writes a `.gsd-source` marker and install-exports resolve in the deployed layout, so `list`/`status` and `profile`/`enable`/`disable`/`reset` no longer throw `could not locate commands/gsd` or `MODULE_NOT_FOUND`. +**`/gsd-surface` works on Claude Code global installs** — the installer now writes a `.gsd-source` marker and install-exports resolve in the deployed layout, so `list`/`status` and `profile`/`enable`/`disable`/`reset` no longer throw `could not locate commands/gsd` or `MODULE_NOT_FOUND`. (#1487) diff --git a/bin/install.js b/bin/install.js index 51987bdf6..3f82cbbf0 100755 --- a/bin/install.js +++ b/bin/install.js @@ -10035,13 +10035,19 @@ function install(isGlobal, runtime = 'claude', options = {}) { // the writer half of the marker that runtime-artifact-layout.cjs's finders // already read (the reader landed in #1476). It points at the package's own // commands/gsd source, whose parent also holds bin/install.js — the path - // loadInstallExports derives the installer exports from. Guarded on source - // presence so a half-published package never writes a dangling marker. - const gsdSourceCommands = path.join(src, 'commands', 'gsd'); - if (fs.existsSync(gsdSourceCommands)) { - try { - fs.writeFileSync(path.join(targetDir, '.gsd-source'), gsdSourceCommands + '\n', 'utf8'); - } catch (_) { /* non-fatal: surface degrades to walk-up resolution */ } + // loadInstallExports derives the installer exports from. Scoped to the + // Claude-global layout (issue #1477) — the only install path that ships the + // skills layout without a commands/gsd source tree; every other runtime/scope + // deploys commands/gsd, so its walk-up already resolves and needs no marker. + // Guarded on source presence so a half-published package never writes a + // dangling marker. + if (runtime === 'claude' && isGlobal) { + const gsdSourceCommands = path.join(src, 'commands', 'gsd'); + if (fs.existsSync(gsdSourceCommands)) { + try { + fs.writeFileSync(path.join(targetDir, '.gsd-source'), gsdSourceCommands + '\n', 'utf8'); + } catch (_) { /* non-fatal: surface degrades to walk-up resolution */ } + } } // Copy shared manifests into the gsd-core payload From 9727e2ae9415dc890d62b0648f6eb1339c36f0e5 Mon Sep 17 00:00:00 2001 From: Joe Slitzker Date: Sat, 20 Jun 2026 13:46:30 -0500 Subject: [PATCH 5/8] fix(#1477): key getInstallExports cache per configDir; document marker contract Address second maintainer review on #1487: - Blocker 1: getInstallExports now caches per runtimeConfigDir (Map) so a no-arg warm-up via the legacy walk-up path cannot poison a later getInstallExports(configDir) call. Add a regression test (verified to fail on the singleton cache) proving the no-arg warm-up does not poison the configDir-keyed resolution. - Blocker 3: document the .gsd-source two-party provisioning contract in the CONTEXT.md Runtime Artifact Layout Module entry (writer, reader, content, guard, fall-through, package-root invariant, per-configDir cache). Claude-Session: https://claude.ai/code/session_01XNT3SWgzjmEycNuweURDme --- CONTEXT.md | 2 +- src/runtime-artifact-layout.cts | 20 ++++++++-- tests/runtime-artifact-layout.test.cjs | 52 ++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 71788c750..8d00e8d0e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -152,7 +152,7 @@ Module owning install detection for `/gsd:update`. `resolveUpdateContext({ home, Module owning which skills and agents are written to runtime config directories at install time (Phase 1) and at runtime via cluster-level toggles (Phase 2). Phase 1: `gsd-core/bin/lib/install-profiles.cjs` defines named profiles (`core`, `standard`, `full`), computes transitive closure over `requires:` frontmatter, stages skills/agents to runtime config dirs, and persists the chosen profile in a `.gsd-profile` marker. Profile resolution precedence: explicit `--profile=` flag > `.gsd-profile` marker > `full`. `--minimal`/`--core-only` are back-compat aliases for `--profile=core`. Phase 2: `gsd-core/bin/lib/surface.cjs` implements the `/gsd:surface` slash command for cluster-level enable/disable without reinstall; cluster definitions live in `gsd-core/bin/lib/clusters.cjs`; per-runtime state persists in `/.gsd-surface.json` independent from the `.gsd-profile` marker. See ADR-0011. ### Runtime Artifact Layout Module -Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `/skills//`) or the flat `skills/gsd-/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. The shared accessor `getInstallExports` (exported from `runtime-artifact-layout.cjs`) is the single-source seam through which `surface.cjs` reaches `computePathPrefix` and `applyRuntimeContentRewritesInPlace`; the resolved `scope` (`'local'`|`'global'`) is now carried on the `Layout` object returned by `resolveRuntimeArtifactLayout` so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). See ADR-3660. +Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `/skills//`) or the flat `skills/gsd-/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. The shared accessor `getInstallExports` (exported from `runtime-artifact-layout.cjs`) is the single-source seam through which `surface.cjs` reaches `computePathPrefix` and `applyRuntimeContentRewritesInPlace`; the resolved `scope` (`'local'`|`'global'`) is now carried on the `Layout` object returned by `resolveRuntimeArtifactLayout` so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). The `.gsd-source` marker (#1477) is a two-party provisioning contract that lets source resolution succeed on the Claude global skills layout, which ships `gsd-core/{bin,contexts,references,templates,workflows}` but no `commands/gsd` source tree for `findInstallSourceRoot` to walk up to: the writer is `bin/install.js`, which writes `/.gsd-source` (content: the absolute path to its own `commands/gsd`, terminated by a newline) when `runtime === 'claude' && isGlobal`, guarded by `fs.existsSync` so a half-published package never writes a dangling marker; the reader is `findInstallSourceRoot(configDir)`, which prefers the marker over its walk-up but falls through to the walk-up if the marker is absent, dangling, or empty/whitespace-only. The marker's package root must also hold `bin/install.js` — `getInstallExports(configDir)` derives the installer-exports path from the resolved source root (`/commands/gsd` → `/bin/install.js`), and caches per `configDir` so a no-arg warm-up call cannot poison later marker-aware resolution. See ADR-3660. ### Runtime Artifact Conversion Module Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index a49738afd..7eb726dc5 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -86,11 +86,23 @@ function loadInstallExports(runtimeConfigDir?: string): InstallExports { } } -/** Cache after first successful load. */ -let _installExports: InstallExports | null = null; +/** + * Cache after first successful load, keyed on runtimeConfigDir. The derived + * install.js path depends on the configDir (marker-aware in a deployed layout + * vs. walk-up in the repo), so a single module-level singleton would let a + * no-arg warm-up call (legacy relative path) poison every later + * getInstallExports(configDir) call. Keying on the arg keeps each layout's + * resolution independent. The empty string stands in for the no-arg case. + */ +const _installExportsByConfigDir = new Map(); function getInstallExports(runtimeConfigDir?: string): InstallExports { - if (!_installExports) _installExports = loadInstallExports(runtimeConfigDir); - return _installExports; + const key = runtimeConfigDir ?? ''; + let exports = _installExportsByConfigDir.get(key); + if (!exports) { + exports = loadInstallExports(runtimeConfigDir); + _installExportsByConfigDir.set(key, exports); + } + return exports; } // --------------------------------------------------------------------------- diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index c3ba616e8..4c3dc4c93 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -775,6 +775,58 @@ describe('#1477 .gsd-source marker provisioning + deployed install-exports resol 'getInstallExports must expose applyRuntimeContentRewritesInPlace (used by applySurface)'); }); + // ── Failure 2 cache correctness: getInstallExports keys on runtimeConfigDir ── + // A module-level singleton cache would let a no-arg warm-up call (legacy + // walk-up path) poison every later getInstallExports(configDir) call — + // applySurface would then load the wrong install.js. Proves the cache is + // keyed per configDir so the marker-derived path always wins for its key. + test('getInstallExports caches per configDir — a no-arg warm-up does not poison a later configDir call', () => { + // A standalone package whose commands/gsd marker derives a sibling + // bin/install.js exporting a sentinel that the real repo install.js lacks. + const pkgRoot = path.join(tmpRoot, 'sentinel-pkg'); + fs.mkdirSync(path.join(pkgRoot, 'commands', 'gsd'), { recursive: true }); + fs.mkdirSync(path.join(pkgRoot, 'bin'), { recursive: true }); + fs.writeFileSync( + path.join(pkgRoot, 'bin', 'install.js'), + "module.exports = { sentinel: 'PKG', computePathPrefix: () => '', applyRuntimeContentRewritesInPlace: () => {} };\n", + 'utf8', + ); + const cfgDir = path.join(tmpRoot, 'sentinel-cfg'); + fs.mkdirSync(cfgDir, { recursive: true }); + fs.writeFileSync( + path.join(cfgDir, '.gsd-source'), + path.join(pkgRoot, 'commands', 'gsd') + '\n', + 'utf8', + ); + + // Fresh module instance so the per-key cache starts empty for this test. + const layoutPath = require.resolve('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + const savedModule = require.cache[layoutPath]; + delete require.cache[layoutPath]; + try { + const fresh = require(layoutPath); + + // Warm the cache via the no-arg legacy walk-up path (resolves the real + // repo bin/install.js, which has no `sentinel`). + const warm = fresh.getInstallExports(); + assert.equal(warm.sentinel, undefined, 'no-arg path resolves the repo install.js'); + + // The configDir call must re-derive from the marker, NOT return the + // cached no-arg result. A singleton cache would return `warm` here. + const derived = fresh.getInstallExports(cfgDir); + assert.equal(derived.sentinel, 'PKG', + 'no-arg warm-up must not poison the configDir-keyed resolution'); + + // Re-querying the same key returns its cached instance, not a re-derive. + assert.strictEqual(fresh.getInstallExports(cfgDir), derived, + 'same configDir key must return the cached instance'); + } finally { + // Restore the original shared module instance for later tests. + if (savedModule) require.cache[layoutPath] = savedModule; + else delete require.cache[layoutPath]; + } + }); + // ── Adversarial marker-reader cases (no full install needed) ───────────────── describe('findInstallSourceRoot marker handling', () => { let cfgDir; From ddfc6b08151d3a1f35fcf86113b0ddfed9ad8e9c Mon Sep 17 00:00:00 2001 From: Joe Slitzker Date: Thu, 25 Jun 2026 09:46:09 -0500 Subject: [PATCH 6/8] fix(#1477): scope changeset to marker writer; lock guard with negative tests Address maintainer re-review nits: - Changeset body no longer claims install-exports resolution (Failure 2 is now handled upstream by #1511 / ADR-1508 Phase 2). This PR delivers the .gsd-source marker writer, which fixes list/status (Failure 1). - Add negative tests locking the `runtime === 'claude' && isGlobal` write guard: a non-claude global install and a claude *local* install both provision no marker. Verified must-fail-first against a relaxed guard. --- .changeset/brave-hawks-fly.md | 2 +- tests/runtime-artifact-layout.test.cjs | 43 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.changeset/brave-hawks-fly.md b/.changeset/brave-hawks-fly.md index a2f75e972..b07b243e8 100644 --- a/.changeset/brave-hawks-fly.md +++ b/.changeset/brave-hawks-fly.md @@ -2,4 +2,4 @@ type: Fixed pr: 1487 --- -**`/gsd-surface` works on Claude Code global installs** — the installer now writes a `.gsd-source` marker and install-exports resolve in the deployed layout, so `list`/`status` and `profile`/`enable`/`disable`/`reset` no longer throw `could not locate commands/gsd` or `MODULE_NOT_FOUND`. (#1487) +**`/gsd-surface` (`list`/`status`) works on Claude Code global installs** — the installer now writes a `.gsd-source` marker pointing at its `commands/gsd` source, so `findInstallSourceRoot` resolves on the global skills layout (which ships no `commands/gsd` tree) instead of throwing `could not locate commands/gsd`. (#1487) diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 74e9f69e6..01680385d 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -726,6 +726,49 @@ describe('#1477 .gsd-source marker provisioning', () => { assert.equal(path.basename(path.dirname(markerSrc)), 'commands'); }); + // ── Guard: marker is scoped to claude-global ONLY (#1477 PR-scope) ─────────── + // Locks the `runtime === 'claude' && isGlobal` write guard. Every other layout + // (non-claude runtimes, and claude *local*) ships a commands/gsd source tree, so + // findInstallSourceRoot's walk-up already resolves and the marker must not appear. + function findGsdSourceMarkers(root) { + const found = []; + const walk = (dir) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full); + else if (e.name === '.gsd-source') found.push(full); + } + }; + walk(root); + return found; + } + + test('non-claude global install writes no .gsd-source marker (locks runtime === claude)', () => { + runInstall(true /* isGlobal */, 'cursor'); + assert.deepEqual( + findGsdSourceMarkers(tmpRoot), [], + 'a non-claude runtime must not provision the claude-global marker', + ); + }); + + test('claude local install writes no .gsd-source marker (locks isGlobal)', () => { + // Local installs target process.cwd()/.claude — chdir into the fixture so the + // install is contained within tmpRoot rather than polluting the repo. + const savedCwd = process.cwd(); + process.chdir(tmpRoot); + try { + runInstall(false /* isGlobal */, 'claude'); + } finally { + process.chdir(savedCwd); + } + assert.deepEqual( + findGsdSourceMarkers(tmpRoot), [], + 'a claude local install must not provision the marker — local ships commands/gsd', + ); + }); + // ── Failure 1 end-to-end: resolution succeeds FROM the deployed tree ───────── // The deployed module's __dirname is /gsd-core/bin/lib, which has no // commands/gsd ancestor (global skills layout). Only the marker rescues it. From bc2e4395accc89c8b2285e728e29f62fe2876596 Mon Sep 17 00:00:00 2001 From: Joe Slitzker Date: Fri, 26 Jun 2026 16:43:31 -0500 Subject: [PATCH 7/8] fix(#1477): warn on marker-write failure; writer fault-injection test Address re-review minors on #1487: - bin/install.js: replace the silent .gsd-source marker catch with a console.warn so a failed write is diagnosable (walk-up also fails on the Claude-global layout, so a swallowed error still breaks /gsd-surface). - tests/runtime-artifact-layout.test.cjs: add a writer fault-injection case (mock fs.writeFileSync to throw for the marker) proving the catch branch is reachable, install stays non-fatal, and the warning is emitted. --- bin/install.js | 7 +++- tests/runtime-artifact-layout.test.cjs | 47 +++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/bin/install.js b/bin/install.js index 5658452cd..c951729b2 100755 --- a/bin/install.js +++ b/bin/install.js @@ -9912,7 +9912,12 @@ function install(isGlobal, runtime = 'claude', options = {}) { if (fs.existsSync(gsdSourceCommands)) { try { fs.writeFileSync(path.join(targetDir, '.gsd-source'), gsdSourceCommands + '\n', 'utf8'); - } catch (_) { /* non-fatal: surface degrades to walk-up resolution */ } + } catch (err) { + // Non-fatal: install proceeds. But on the Claude-global layout walk-up + // also fails (no commands/gsd source tree), so a silent write failure + // still leaves /gsd-surface broken at runtime — warn so it's diagnosable. + console.warn(` ${yellow}!${reset} Could not write .gsd-source marker (${err.message}); /gsd-surface list/status may fail`); + } } } diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 250b14bfa..ecb35b783 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -17,7 +17,7 @@ * runtime-artifact-layout-install-profiles.test.cjs — install-profiles seam */ -const { test, describe, beforeEach, afterEach } = require('node:test'); +const { test, describe, beforeEach, afterEach, mock } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); @@ -776,6 +776,51 @@ describe('#1477 .gsd-source marker provisioning', () => { ); }); + // ── Writer fault-injection: marker write failure is non-fatal + warns ──────── + // CONTRIBUTING.md filesystem-write QA matrix: prove the marker-writer catch + // branch (bin/install.js) is reachable. A failed write (e.g. read-only target) + // must not abort the install, and — because walk-up also fails on this layout — + // must surface a diagnostic so the broken /gsd-surface is traceable. + test('marker write failure does not abort install and warns (locks the writer catch)', () => { + const claudeDir = path.join(tmpRoot, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + + const markerPath = path.join(claudeDir, '.gsd-source'); + const realWriteFileSync = fs.writeFileSync; + // Fault-inject ONLY the marker write; every other install write proceeds. + mock.method(fs, 'writeFileSync', (file, ...rest) => { + if (path.resolve(String(file)) === path.resolve(markerPath)) { + throw new Error('EACCES: read-only target (injected)'); + } + return realWriteFileSync(file, ...rest); + }); + + // Capture console.warn around the install (runInstall's silenceConsole would + // otherwise swallow it); still guard process.exit. + const warnings = []; + const origExit = process.exit; + const origWarn = console.warn; + const origLog = console.log; + process.exit = (code) => { throw new Error(`process.exit(${code}) during install`); }; + console.warn = (msg) => { warnings.push(String(msg)); }; + console.log = () => {}; + try { + assert.doesNotThrow(() => install(true /* isGlobal */, 'claude'), + 'a marker write failure must be non-fatal — install proceeds via the catch'); + } finally { + process.exit = origExit; + console.warn = origWarn; + console.log = origLog; + mock.restoreAll(); + } + + assert.ok(!fs.existsSync(markerPath), 'the injected fault must leave no marker on disk'); + assert.ok( + warnings.some((w) => /\.gsd-source marker/.test(w)), + `the writer catch must warn for diagnosability; got: ${JSON.stringify(warnings)}`, + ); + }); + // ── Failure 1 end-to-end: resolution succeeds FROM the deployed tree ───────── // The deployed module's __dirname is /gsd-core/bin/lib, which has no // commands/gsd ancestor (global skills layout). Only the marker rescues it. From 48507e927bce96389c043897e21ac0c9b79df97c Mon Sep 17 00:00:00 2001 From: Joe Slitzker Date: Fri, 26 Jun 2026 17:07:36 -0500 Subject: [PATCH 8/8] test(#1477): exclude .gsd-source from golden parity manifest The claude-global install now provisions .gsd-source (#1477), whose content is the install-time absolute path to the package commands/gsd source tree. That path is the checkout/CI workspace root, not the temp HOME, so it is never normalized to and its hash varies by environment. Exclude it from the parity manifest as a volatile metadata file, same rationale as gsd-install-state.json. --- tests/golden-install-parity.test.cjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/golden-install-parity.test.cjs b/tests/golden-install-parity.test.cjs index f70458669..32eef28d1 100644 --- a/tests/golden-install-parity.test.cjs +++ b/tests/golden-install-parity.test.cjs @@ -11,10 +11,11 @@ * * After replacing every occurrence of the temp root path with the literal * '' in file contents, the install output is byte-identical run-to-run - * for ALL files EXCEPT exactly two volatile metadata files that are EXCLUDED + * for ALL files EXCEPT the volatile metadata files that are EXCLUDED * from the parity manifest: * - gsd-file-manifest.json (timestamp + install-time absolute paths) * - gsd-install-state.json (install-time absolute paths) + * - .gsd-source (#1477, claude-global: install-time source path) * * Everything else (≈545–616 files per runtime) is deterministic. * @@ -48,7 +49,11 @@ const UPDATE = process.env.UPDATE_GOLDEN === '1'; const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'golden-install-parity'); // Volatile metadata files always excluded from the parity manifest. -const VOLATILE_FILES = new Set(['gsd-file-manifest.json', 'gsd-install-state.json']); +// .gsd-source (#1477, claude-global only) records the install-time absolute path +// to the package's commands/gsd source tree, which is the checkout/CI workspace +// path — NOT the temp HOME root, so it is never normalized to '' and its +// hash varies by environment. Excluded for the same reason as gsd-install-state.json. +const VOLATILE_FILES = new Set(['gsd-file-manifest.json', 'gsd-install-state.json', '.gsd-source']); // Hook-registration config files excluded from the parity manifest. These are // written by the hook/permission install path (applySettingsJsonHooks /