From 2f0e99f9e02c519d18adc16c12a32fa7218f2222 Mon Sep 17 00:00:00 2001 From: Michel Moreira Date: Tue, 15 Sep 2026 04:40:04 -0300 Subject: [PATCH] fix(#4377): opt in to project-relative includes for local installs (#4425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance(#4377): opt-in project-relative includes for local installs A local install wrote the includes that point at GSD's own files as absolute paths — whatever the installer resolved at install time. For one checkout that is invisible. Across git worktrees it is not: each worktree gets its own .claude/ copy, but all of them point back at the checkout that ran the installer, so a worktree runs its own gsd-tools.cjs while reading workflow prose from a different checkout. Update that one checkout and every other worktree is running new instructions against an old engine, with nothing to stage the update with. --relative-includes (or GSD_RELATIVE_INCLUDES=1) makes a local install emit `@.claude/gsd-core/...`. Opt-in, and staying opt-in: absolute works for a single checkout, which is most people, and flipping the default would change every existing local install to solve a problem those users do not have. The prefix is the runtime's own localConfigDir descriptor value, never a literal — the same value resolveScope joins onto the cwd to produce the install target, and the same one the rewrite engine already uses for its ./.claude/ -> .// substitutions. Copilot and Antigravity have shipped this shape for local installs since they were added, with hardcoded .github/ and .agents/; this is that behavior, derived rather than written down. Six seams compute a path prefix and all six had to be threaded, which is why the opt-in travels through the environment the way --portable-hooks already does: one variable they all read cannot fall out of sync the way six signatures can. The launcher shim deliberately keeps its ABSOLUTE fallbacks. It probes gsd-tools through ${CLAUDE_CONFIG_DIR:-$HOME/.claude} and one such default per runtime; those are shell word expansions, not includes, and a relative value there resolves against the shell's cwd rather than the project. Trading an include that points at the wrong checkout for a path that points at nothing is not a fix. All three rewrite paths mask ${VAR:-default} spans before substituting and restore them after, and the mask only runs when the prefix is relative, so an absolute install is byte-for-byte unchanged. Every unexpressible case falls back to absolute: no opt-in, a global install, a missing dir name, the configHome.kind === 'none' sentinel, an absolute descriptor value, or one climbing out of the project with '..'. * chore(#4377): add changeset for project-relative local includes * fix(#4377): compare against POSIX-normalized roots in the install e2e arms The emitted prefix is POSIX-normalized by design — it is substituted into markdown @-references, which use forward slashes universally, so a backslash would leak into shipped content (#1615). The e2e arms compared against the raw temp root, which on Windows is `D:\a\...` and appears in no emitted file. That reddened the control arm on the windows shard, and it was worse than a red: the negative arm ("nothing references the checkout") was passing VACUOUSLY there, because a string that cannot occur is trivially absent. Both now go through the same normalization, so the Windows lane asserts what the Linux lane does. * fix(#4377): tolerate a resolved temp root, and make the e2e diff self-diagnosing Two changes, one confirmed and one to stop guessing. Confirmed: the emitted content carries the RESOLVED root, not the spelling mkdtemp handed back. Reproduced on Linux with a symlinked install root — 236 emitted files carry the realpath, zero carry the link path. macOS has this structurally, since /var is a symlink to /private/var. Comparisons now go through both spellings, or the negative arms pass vacuously: "nothing references the checkout" is trivially true when the string being searched for cannot occur. Not confirmed: the macOS shard reported ~every workflow file differing in the "differ ONLY" arm while the five arms around it passed, and the assertion printed a list of filenames — which says a difference exists somewhere across 236 files and leaves the reader to guess which bytes. I cannot reproduce that platform locally, and guessing turns one CI round-trip into four. The assertion now reports the first divergence as text: the file, the byte offset, and a bounded window of both sides. * fix(#4377): strip the longest root spelling first in the install e2e diff The macOS failure was my test corrupting its own comparison, not a product defect. /var/folders/…/X is a SUBSTRING of /private/var/folders/…/X, so stripping the unresolved spelling first matched inside the resolved one and left the /private prefix glued to what followed: @/private/var/…/X/.claude/gsd-core/… -> @/private.claude/gsd-core/… a string present in neither install, which is why all 236 files "differed". Sorting the spellings longest-first consumes the whole occurrence, and the short form then has nothing left to match. Proven in isolation on the exact macOS shapes: short-first yields @/private.claude/…, longest-first yields @.claude/…. The self-diagnosing assertion added in the previous commit is what found this — it named the file, the byte offset, and printed both sides, so the corrupted string was visible rather than inferred from a list of 236 filenames. Keeping it. * fix(#4377): address review findings * test(#4377): scan nested shell defaults without regex backtracking * fix(#4377): close relative include review gaps * fix(#4377): preserve root-target runtime includes * fix(#4377): guard project-root relative includes * test(#4377): normalize Cline fallback roots * fix(#4377): persist relative include style --------- Co-authored-by: Tom Boucher --- .changeset/patient-finches-rest.md | 5 + CONTEXT.md | 2 +- bin/install.js | 126 ++++++++---- docs/how-to/install-on-your-runtime.md | 42 ++++ src/install-engine.cts | 3 + src/runtime-artifact-conversion.cts | 238 ++++++++++++++++++++++- src/runtime-artifact-install-plan.cts | 13 +- src/surface.cts | 18 +- tests/install-runtime-artifacts.test.cjs | 208 ++++++++++++++++++++ tests/install.test.cjs | 220 +++++++++++++++++++++ 10 files changed, 830 insertions(+), 45 deletions(-) create mode 100644 .changeset/patient-finches-rest.md diff --git a/.changeset/patient-finches-rest.md b/.changeset/patient-finches-rest.md new file mode 100644 index 000000000..e3c6502a6 --- /dev/null +++ b/.changeset/patient-finches-rest.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4425 +--- +**Local installs can keep `@` includes inside each worktree** — `--relative-includes` (or `GSD_RELATIVE_INCLUDES=1`) writes project-relative includes instead of binding every worktree to whichever checkout ran the installer. (#4377) diff --git a/CONTEXT.md b/CONTEXT.md index 7240d0e54..3f94e850e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -321,7 +321,7 @@ Module owning the per-runtime mapping from artifact kind to filesystem placement Issue #4132 keeps `resolveRuntimeArtifactLayout` as the single no-I/O placement resolver. Ordinary kind stage closures lazily select one complete provider shared by every source class the layout requires. Global installs provision canonical raw commands under `gsd-core/commands/gsd` and agents under `gsd-core/agents`, with manifest-owned refresh/removal; installer staging first uses that normal installed/marker selection and privately retries against the executing package only when source resolution fails before creating any staged output. Deployed Runtime Surface staging prefers the complete installation-owned corpus, then a complete independent legacy `.gsd-source` compatibility provider, and otherwise fails closed with an install/upgrade diagnostic. Provider independence and installed-corpus integrity are synchronous admission checks over the filesystem state observed during provider selection; same-user concurrent mutation after resolution remains outside this issue's threat model. No layout may mix commands and agents from different providers. ### 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. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate `bin/install.js`-owned pre-conversion step (`applyOpencodeFamilyPathPrefix`) per #784, not a violation of the single-owner rule. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs`. Also exports `resolveVersionFrom(libDir)` — a lazy, defensive GSD-version resolver (installed-tree `gsd-core/VERSION` first, then the source/npm `package.json` three dirs up, both validated against the repo's shared semver-prefix shape, degrading to `''` on failure) that replaced a module-load-time `require('../../../package.json')` which crashed on runtimes whose root carries no `package.json` (e.g. Codex) (#1383). #4032 added `appendAgentTools` as step 3 of the ADR-1235 pre-converter pipeline (`stageAgentsForRuntimeWithConverter`, `src/install-profiles.cts` — not this module): appends `readGsdEffectiveAgentTools` grants (Install Model Override Resolver Module) into the canonical agent's `tools:` frontmatter — line-surgical, both inline-comma and YAML block-list forms — before the runtime-specific converter runs, so every converter (including Kilo's `mcp__*`→`{server}_{tool}` permission mapping and ZCode's `mcp__*` omission policy) sees configured grants without a duplicated per-runtime write path. +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. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. #4377 extends `computePathPrefix` with `projectRelative?` and `localDirName?`: an explicitly opted-in local install may emit the descriptor-derived project-relative prefix, while globals and unsafe/unrepresentable descriptor values retain the absolute fallback. The exported test seams `_relativeIncludesEnabled`, `_projectRelativePrefix`, `_isRelativePathPrefix`, and `_withShellDefaultsPreserved` pin that policy; the last is also the single balanced `${VAR:-...}` masking implementation used by both rewrite paths so nested launcher defaults remain absolute. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate `bin/install.js`-owned pre-conversion step (`applyOpencodeFamilyPathPrefix`) per #784, not a violation of the single-owner rule. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs`. Also exports `resolveVersionFrom(libDir)` — a lazy, defensive GSD-version resolver (installed-tree `gsd-core/VERSION` first, then the source/npm `package.json` three dirs up, both validated against the repo's shared semver-prefix shape, degrading to `''` on failure) that replaced a module-load-time `require('../../../package.json')` which crashed on runtimes whose root carries no `package.json` (e.g. Codex) (#1383). #4032 added `appendAgentTools` as step 3 of the ADR-1235 pre-converter pipeline (`stageAgentsForRuntimeWithConverter`, `src/install-profiles.cts` — not this module): appends `readGsdEffectiveAgentTools` grants (Install Model Override Resolver Module) into the canonical agent's `tools:` frontmatter — line-surgical, both inline-comma and YAML block-list forms — before the runtime-specific converter runs, so every converter (including Kilo's `mcp__*`→`{server}_{tool}` permission mapping and ZCode's `mcp__*` omission policy) sees configured grants without a duplicated per-runtime write path. ### Runtime Artifact Install Plan Module Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. **Write-confinement (ADR-1239 Phase B / #1679):** the exported pure `assertDestWithinConfigHome(configDir, destSubpath) -> resolvedDest` is the security gate — every kind's `destDir` is computed through it on both the install and uninstall plan paths, so a `destSubpath` that escapes `configHome` (`../../etc`, a NUL byte, etc.) is rejected at plan-build time with a clear error; `surface.cjs:applySurface` and `bin/install.js:installOpencodeFamilySkills` route their joins through the same helper, and `_copyStaged` carries a defense-in-depth containment check. This is security-load-bearing for the Phase C third-party-descriptor loader (which is where an untrusted `destSubpath` could arrive). Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs`. See Runtime Artifact Layout Module and Runtime Artifact Conversion Module. diff --git a/bin/install.js b/bin/install.js index 3cff2c902..8fa65f22c 100755 --- a/bin/install.js +++ b/bin/install.js @@ -937,6 +937,25 @@ const hasSkillsRoot = args.includes('--skills-root'); const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1'; const hasMinimal = args.includes('--minimal') || args.includes('--core-only'); const hasDryRun = args.includes('--dry-run'); +// #4377: emit project-relative `@` includes (`.claude/gsd-core/...`) for a +// LOCAL install instead of this checkout's absolute path. +// +// Opt-in, and it stays opt-in: absolute includes work for a single checkout, +// which is nearly everyone, and flipping the default would change every +// existing local install to solve a problem those users do not have. The +// people who need it know they do — they run the same repo from several git +// worktrees, where a baked absolute path means every worktree reads its +// workflow prose out of whichever checkout happened to run the installer, and +// updating that one checkout breaks all the others at once with no way to +// stage it. +// +// Exported through the environment rather than threaded as a parameter, +// exactly like --portable-hooks/GSD_PORTABLE_HOOKS above: five separate seams +// compute a path prefix (the install engine, both rewrite entry points, the +// install plan, and applySurface), and one variable they all read cannot fall +// out of sync the way five signatures can. +const hasRelativeIncludes = args.includes('--relative-includes') || process.env.GSD_RELATIVE_INCLUDES === '1'; +if (hasRelativeIncludes) process.env.GSD_RELATIVE_INCLUDES = '1'; // #3031: opt-in reclaim of the GSD artifacts a PRE-#2755 `--kimi-code` install // orphaned in Kimi CLI's `~/.kimi`. Opt-in and not automatic because the stale // block is BYTE-IDENTICAL to a legitimate Kimi CLI one — both runtimes render @@ -1264,7 +1283,7 @@ if (hasUninstall) { // Show help if requested if (hasHelp) { - console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--kimi-code${reset} Install for Kimi Code only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--pi${reset} Install for Pi only\n ${cyan}--gemini${reset} Install for Gemini CLI only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}--no-legacy-cleanup${reset} Skip the legacy get-shit-done-cc artifact scan\n (an explicit --config-dir already scopes the scan to it)\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n and resolve the node runner at hook-fire time via\n hooks/gsd-node-runner.sh (WSL/Docker bind-mount\n setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--reclaim-kimi-legacy${reset} With --kimi-code: also remove the GSD hooks a\n pre-1.10.0 --kimi-code install orphaned in ~/.kimi.\n Opt-in — those artifacts are indistinguishable from\n Kimi CLI's own, so skip it if you use Kimi CLI too.\n ${cyan}--profile=${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi Code globally (its own ~/.kimi-code root)${reset}\n npx ${pkg.name} --kimi-code --global\n\n ${dim}# Kimi Code, also reclaiming hooks a pre-1.10.0 install left in ~/.kimi${reset}\n npx ${pkg.name} --kimi-code --global --reclaim-kimi-legacy\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Kimi CLI and Kimi Code are separate products with separate hook roots: use ${cyan}--kimi${reset} (${cyan}~/.kimi${reset}, ${cyan}KIMI_SHARE_DIR${reset}) or ${cyan}--kimi-code${reset} (${cyan}~/.kimi-code${reset}, ${cyan}KIMI_CODE_HOME${reset}).\n`); + console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--kimi-code${reset} Install for Kimi Code only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--pi${reset} Install for Pi only\n ${cyan}--gemini${reset} Install for Gemini CLI only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}--no-legacy-cleanup${reset} Skip the legacy get-shit-done-cc artifact scan\n (an explicit --config-dir already scopes the scan to it)\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n and resolve the node runner at hook-fire time via\n hooks/gsd-node-runner.sh (WSL/Docker bind-mount\n setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--relative-includes${reset} With --local: write project-relative @ includes\n (.claude/gsd-core/...) instead of this checkout's\n absolute path, so several git worktrees of one repo\n each read their own copy (also GSD_RELATIVE_INCLUDES=1)\n ${cyan}--reclaim-kimi-legacy${reset} With --kimi-code: also remove the GSD hooks a\n pre-1.10.0 --kimi-code install orphaned in ~/.kimi.\n Opt-in — those artifacts are indistinguishable from\n Kimi CLI's own, so skip it if you use Kimi CLI too.\n ${cyan}--profile=${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi Code globally (its own ~/.kimi-code root)${reset}\n npx ${pkg.name} --kimi-code --global\n\n ${dim}# Kimi Code, also reclaiming hooks a pre-1.10.0 install left in ~/.kimi${reset}\n npx ${pkg.name} --kimi-code --global --reclaim-kimi-legacy\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Local install for a repo worked from several git worktrees${reset}\n npx ${pkg.name} --claude --local --relative-includes\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Kimi CLI and Kimi Code are separate products with separate hook roots: use ${cyan}--kimi${reset} (${cyan}~/.kimi${reset}, ${cyan}KIMI_SHARE_DIR${reset}) or ${cyan}--kimi-code${reset} (${cyan}~/.kimi-code${reset}, ${cyan}KIMI_CODE_HOME${reset}).\n`); process.exit(0); } @@ -7933,44 +7952,63 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand content = filterRuntimeNotesForTarget(content, runtime); if (!dispatch.mdSkipGenericRewrite) { - const globalClaudeRegex = /~\/\.claude\//g; - const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; - const localClaudeRegex = /\.\/\.claude\//g; - content = content.replace(globalClaudeRegex, pathPrefix); - content = content.replace(globalClaudeHomeRegex, pathPrefix); - content = content.replace(localClaudeRegex, `./${dirName}/`); - // #3544 review (Finding 1 fallout): guarded with the SAME - // negative-lookahead convention already used at ~:2859-2860 below - // ("preserve .claude-plugin and .claudeignore"). A naive `\b` here - // is satisfied by ANY non-word character, including '-' — so for a - // --config-dir whose name EXTENDS '.claude' (e.g. '.claude-work', - // pathPrefix '$HOME/.claude-work/'), this pass re-matched the - // '$HOME/.claude' PREFIX of its own slash-form output (lines above) - // and re-appended the full prefix, corrupting every emitted path to - // '$HOME/.claude-work-work/...'. Harmless no-op for the literal - // default '.claude' (self-replace with an identical string), which - // is why this went undetected until a non-default config-dir name - // was exercised. - content = content.replace(/~\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, '')); - content = content.replace(/\$HOME\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, '')); - content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); - content = content.replace(/~\/\.qwen\//g, pathPrefix); - content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix); - content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`); - content = content.replace(/~\/\.hermes\//g, pathPrefix); - content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix); - content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`); - // #3544: restore @-file-reference lines to the tilde form Claude Code - // actually expands — the SAME correction #3133 already applies to - // skill/command bodies via _applyRuntimeRewrites's 'claude' case (see - // restoreClaudeGlobalAtRefTilde's doc comment in - // runtime-artifact-conversion.cts). This is the gsd-core/ spec-tree - // emit path, which never had it: every @~/.claude/gsd-core/… include - // in a global install's workflows/references tree silently resolved - // to nothing (54 includes across 22 files on a live install). - if (runtime === 'claude') { - content = runtimeArtifactConversion._restoreClaudeGlobalAtRefTilde(content, pathPrefix); - } + // #4377: with a project-relative prefix, mask `${VAR:-default}` shell + // defaults out of the substitutions below and restore them after. The + // runtime launcher snippet probes gsd-tools through a chain of those + // (`${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/...`, one per + // runtime); they are shell word expansions, not markdown @ includes, + // and a relative value there resolves against the shell's cwd instead + // of the project. Swapping an include that points at the wrong + // checkout for a path that points at nothing is not a fix, and the + // launcher already probes `$(git rev-parse --show-toplevel)/.claude` + // first, so the multi-worktree case is handled before these defaults + // are ever reached. The shared helper is the single owner of the + // balanced masking grammar used by this path and the rewrite engine. + const rewriteGenericPaths = (body) => { + content = body; + const globalClaudeRegex = /~\/\.claude\//g; + const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; + const localClaudeRegex = /\.\/\.claude\//g; + content = content.replace(globalClaudeRegex, pathPrefix); + content = content.replace(globalClaudeHomeRegex, pathPrefix); + content = content.replace(localClaudeRegex, `./${dirName}/`); + // #3544 review (Finding 1 fallout): guarded with the SAME + // negative-lookahead convention already used at ~:2859-2860 below + // ("preserve .claude-plugin and .claudeignore"). A naive `\b` here + // is satisfied by ANY non-word character, including '-' — so for a + // --config-dir whose name EXTENDS '.claude' (e.g. '.claude-work', + // pathPrefix '$HOME/.claude-work/'), this pass re-matched the + // '$HOME/.claude' PREFIX of its own slash-form output (lines above) + // and re-appended the full prefix, corrupting every emitted path to + // '$HOME/.claude-work-work/...'. Harmless no-op for the literal + // default '.claude' (self-replace with an identical string), which + // is why this went undetected until a non-default config-dir name + // was exercised. + content = content.replace(/~\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, '')); + content = content.replace(/\$HOME\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, '')); + content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); + content = content.replace(/~\/\.qwen\//g, pathPrefix); + content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix); + content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`); + content = content.replace(/~\/\.hermes\//g, pathPrefix); + content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix); + content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`); + // #3544: restore @-file-reference lines to the tilde form Claude Code + // actually expands — the SAME correction #3133 already applies to + // skill/command bodies via _applyRuntimeRewrites's 'claude' case (see + // restoreClaudeGlobalAtRefTilde's doc comment in + // runtime-artifact-conversion.cts). This is the gsd-core/ spec-tree + // emit path, which never had it: every @~/.claude/gsd-core/… include + // in a global install's workflows/references tree silently resolved + // to nothing (54 includes across 22 files on a live install). + if (runtime === 'claude') { + content = runtimeArtifactConversion._restoreClaudeGlobalAtRefTilde(content, pathPrefix); + } + return content; + }; + content = runtimeArtifactConversion._isRelativePathPrefix(pathPrefix) + ? runtimeArtifactConversion._withShellDefaultsPreserved(content, rewriteGenericPaths) + : rewriteGenericPaths(content); } content = processAttribution(content, getCommitAttribution(runtime)); @@ -9909,6 +9947,11 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { // it from the directory it happened to be found in (#2872). runtime, scope: resolvedScope, + // #4377: a surface re-apply is a separate process and cannot rely on the + // installer's environment. Persist only a safe project-relative prefix. + relativeIncludePrefix: resolvedScope === 'local' && hasRelativeIncludes + ? runtimeArtifactConversion._projectRelativePrefixFromProjectRoot(process.cwd(), configDir) + : undefined, files: {}, }; @@ -10792,6 +10835,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { isWindowsHost, resolvedTarget, homeDir, + // #4377: the runtime's own localConfigDir. This is the prefix that reaches + // copyWithPathReplacement, i.e. the one actually written into every + // emitted command/skill/workflow body — the rewrite-engine seams below + // handle re-applied surfaces, not the first install. + localDirName: _hostBehaviors(runtime).localTargetIsProjectRoot === true ? undefined : getDirName(runtime), }); // runtimeLabel is now the single-source getRuntimeLabel lookup (ADR-1239 diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 2b92ad7ca..4fb9ddcf9 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -553,6 +553,48 @@ diagnostic and exits non-zero when nothing resolves. --- +## Local installs across several git worktrees + +A local (`--local`) install writes the includes that point at GSD's own installed +files as absolute paths — the path the installer resolved at install time. For a +single checkout that is invisible and works fine. + +It stops working once the same repository is checked out more than once. Each git +worktree gets its own `.claude/` copy, but every one of those copies points back at +the checkout that ran the installer. So a worktree runs its own `gsd-tools.cjs` +(correctly resolved through `git rev-parse --show-toplevel`) while reading its +workflow prose out of a different checkout — and the moment you update that one +checkout, every other worktree is executing new instructions against an old engine, +with no way to stage the update. + +Install with `--relative-includes` (or `GSD_RELATIVE_INCLUDES=1`) to write the +includes relative to the project instead: + +```bash +npx @opengsd/gsd-core@latest --claude --local --relative-includes +``` + +Every emitted `@` include then reads `@.claude/gsd-core/...` rather than +`@/absolute/path/to/checkout/.claude/gsd-core/...`, so each worktree resolves its +own copy and the worktrees become independent. + +Notes: + +- **It is opt-in and stays opt-in.** Absolute includes work for a single checkout, + which is most people; the flag exists for those who need it. +- **Global installs are unaffected.** They keep their `$HOME`-relative form, which + is already checkout-independent. +- **The runtime launcher keeps its absolute fallbacks.** The shell snippet that + locates `gsd-tools.cjs` probes `${CLAUDE_CONFIG_DIR:-$HOME/.claude}` and one such + default per runtime; those are shell word expansions, not includes, and a relative + value there would resolve against the current shell's directory rather than the + project. The launcher already probes `$(git rev-parse --show-toplevel)/.claude` + first, so it finds the current worktree before it ever reaches those defaults. +- **`--relative-includes` with `--global` does nothing.** The flag is only consulted + for local installs. + +--- + ## Installing without Node.js If you cannot run `npx` (for example, on a Windows machine without Node.js), you have two options. diff --git a/src/install-engine.cts b/src/install-engine.cts index 2de0f1ef6..058da129e 100644 --- a/src/install-engine.cts +++ b/src/install-engine.cts @@ -2044,6 +2044,9 @@ function installOpencodeFamilyArtifacts( isWindowsHost: process.platform === 'win32', resolvedTarget: posixNormalize(path.resolve(configDir)), homeDir: posixNormalize(os.homedir()), + // #4377: the runtime's own localConfigDir, so an opted-in local install + // emits `/...` instead of this checkout's absolute path. + localDirName: runtimeArtifactConversion._localIncludeDirName(runtime), }); // #2329: destDir is derived from the SAME hostBehaviors.flatCommandDir diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 4e126adee..10d39613e 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -2971,15 +2971,64 @@ function convertClaudeCommandToKiloSkill(content, skillName) { // to the originals; the only change is the injected `attribution` 5th param in // _applyRuntimeRewrites (replacing the internal getCommitAttribution() call). +/** + * #4377: is the project-relative include style opted in? + * + * Dual-sourced exactly like `--portable-hooks`/`GSD_PORTABLE_HOOKS`: + * `bin/install.js` sets the variable when the flag is passed, so the flag and + * the environment cannot disagree, and every seam that computes a path prefix + * (the installer copy path, the install engine, the two rewrite entry points, + * the install plan, and `applySurface`) reads the same answer without six signatures having to + * grow a parameter each and stay in sync. + * + * Opt-in, not the new default. Making relative the default would change every + * existing single-checkout local install — which works today — to fix a + * multi-worktree case those users do not have. + * + * @param env - Environment to read (injectable for tests). + * @returns Whether local installs should emit project-relative includes. + */ +function relativeIncludesEnabled(env = process.env): boolean { + return env.GSD_RELATIVE_INCLUDES === '1'; +} + /** * Compute the path prefix for a runtime install. * Global installs under $HOME use $HOME/... form; others use the resolved target. * isOpencode excludes OpenCode (uses ~/.config/opencode which breaks $HOME shorthand). * isWindowsHost is not used today but reserved for future Windows-specific logic. * + * #4377: a LOCAL install can instead emit a project-relative prefix, so a repo + * worked from several git worktrees does not get every worktree's `@` includes + * baked to whichever checkout happened to run the installer. Absolute is still + * the default; `projectRelative` opts in. + * + * `localDirName` is the runtime's own `localConfigDir` descriptor value + * (via `getDirName`), never a hardcoded literal — the same value the rewrite + * engine already uses for its `./.claude/` -> `.//` substitutions, and + * exactly what `resolveScope` joins onto the cwd to produce `resolvedTarget` + * for a local install. Copilot and Antigravity have shipped this shape for + * local installs since they were added, with hardcoded `.github/` and + * `.agents/`; this is the same behavior, derived instead of written down. + * + * Fails safe to the absolute prefix: no opt-in, a global install, a missing + * dir name, or the `configHome.kind === 'none'` sentinel all fall through. A + * wrong-but-absolute include still resolves to a real file; a wrong relative + * one silently resolves against whatever the reader's cwd happens to be. + * * @private — exported as `_computePathPrefix` for tests. */ -function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget, homeDir }) { +function computePathPrefix({ + isGlobal, + isOpencode, + isWindowsHost: _isWindowsHost, + resolvedTarget, + homeDir, + projectRelative = relativeIncludesEnabled(), + localDirName, + projectRoot = process.cwd(), + projectRelativePath, +}) { // #1615: normalize Windows backslashes to forward slashes. This prefix is // substituted into markdown @-references (e.g. Windsurf workflow files), // which use POSIX paths universally. Idempotent on POSIX (no backslashes). @@ -2991,9 +3040,59 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost if (isGlobal && posixTarget.startsWith(posixHome) && !isOpencode) { return '$HOME' + posixTarget.slice(posixHome.length) + '/'; } + if (!isGlobal && projectRelative) { + // An explicit local config dir need not be the descriptor's conventional + // `.claude`-style directory. Derive from the resolved target first; only + // use the descriptor as the legacy fallback when no project root exists. + const relative = projectRelativePrefix(projectRelativePath) + || projectRelativePrefixFromProjectRoot(projectRoot, resolvedTarget) + || projectRelativePrefix(localDirName); + if (relative) return relative; + } return `${posixTarget}/`; } +/** Return a safe project-relative prefix for a resolved install target. */ +function projectRelativePrefixFromProjectRoot(projectRoot: unknown, resolvedTarget: unknown): string { + if (typeof projectRoot !== 'string' || typeof resolvedTarget !== 'string') return ''; + const relative = posixNormalize(path.relative(projectRoot, resolvedTarget)); + if (!relative || relative === '.' || relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) return ''; + return projectRelativePrefix(relative); +} + +/** + * A runtime installed directly at the project root cannot use its descriptor + * directory in a project-relative include: that directory was never created. + */ +function localIncludeDirName(runtime: string): string | undefined { + return _hostBehaviors(runtime).localTargetIsProjectRoot === true ? undefined : getDirName(runtime); +} + +/** + * #4377: the project-relative prefix for a local install, or `''` when the + * runtime cannot express one and the caller must fall back to absolute. + * + * Rejects the `configHome.kind === 'none'` sentinel (a runtime with no local + * config dir at all — interpolating it would produce a literal + * `(no-local-config-dir)/` path segment), anything absolute, and anything that + * climbs out of the project with `..`. Trailing slashes are normalized so a + * descriptor value written either way yields one prefix. + * + * @param localDirName - The runtime's `localConfigDir` descriptor value. + * @returns A `dir/` prefix, or `''` to signal "use the absolute form". + */ +function projectRelativePrefix(localDirName): string { + if (typeof localDirName !== 'string' || localDirName.length === 0) return ''; + if (localDirName === runtimeNamePolicy.NO_LOCAL_CONFIG_DIR_SENTINEL) return ''; + const normalized = posixNormalize(localDirName).replace(/\/+$/, ''); + if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) return ''; + // Reject a climb in ANY segment, not only at the beginning. posixNormalize + // deliberately normalizes separators rather than resolving path segments, + // so `nested/../../outside` must be caught explicitly here. + if (normalized.split('/').includes('..')) return ''; + return `${normalized}/`; +} + /** * Canonical list of every non-Claude runtime that gsd-core emits artifacts for. * DERIVED from the capability registry (ADR-1239 Phase B, #1679) — the registry's @@ -3174,7 +3273,114 @@ function restoreClaudeGlobalAtRefTilde(content, pathPrefix) { * * @private — exported as `_applyRuntimeRewrites` for tests. */ +/** + * #4377: is this prefix a project-relative one? + * + * Anything not rooted -- no leading `/`, no `$HOME`, no `~`, no drive letter. + * Used only to decide whether the shell-default guard below needs to run, so + * an absolute install is byte-for-byte untouched by any of this. + * + * @param pathPrefix - The computed prefix. + * @returns Whether it resolves relative to something. + */ +function isRelativePathPrefix(pathPrefix): boolean { + if (typeof pathPrefix !== 'string' || pathPrefix.length === 0) return false; + return !(pathPrefix.startsWith('/') + || pathPrefix.startsWith('$HOME') + || pathPrefix.startsWith('~') + || /^[A-Za-z]:/.test(pathPrefix)); +} + +/** + * #4377: shield `${VAR:-default}` shell defaults from a RELATIVE path prefix. + * + * The runtime launcher snippet probes for gsd-tools through a chain of shell + * defaults -- `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/...`, one per + * runtime. Those are shell word expansions, not markdown `@` includes, and + * they are the one place where substituting a project-relative prefix makes + * things WORSE rather than better: `$HOME/.claude` resolves the same from + * anywhere, while a bare `.claude` resolves against whatever directory the + * shell happens to be sitting in. Trading an include that points at the wrong + * checkout for a path that points at nothing is not a fix. + * + * The issue asked for project-relative INCLUDES, and this keeps the change to + * exactly that. The launcher already handles the multi-worktree case on its + * own, and better -- it probes `$(git rev-parse --show-toplevel)/.claude/...` + * first, so it finds the current worktree's copy long before it reaches these + * defaults. + * + * The mask token is `@@GSD4377:@@`. It has to survive every substitution in + * the rewrite body untouched, so it deliberately contains no `.claude`, no + * `~`, no `$HOME` and no path separator -- there is nothing in it for those + * regexes to match. A collision would need that literal to already exist in + * the shipped corpus, which is asserted against in the tests. + * + * Only runs when the prefix is relative, so an absolute install never sees + * this transformation at all. + * + * @param content - The body being rewritten. + * @param rewrite - The substitution pass to run on the unguarded remainder. + * @returns The rewritten body, with every shell default restored verbatim. + */ +function withShellDefaultsPreserved(content, rewrite) { + const preserved: string[] = []; + let masked = ''; + let copiedThrough = 0; + let searchFrom = 0; + + // Scan balanced `${...}` expansions instead of stopping at the first `}`. + // The launcher has nested defaults such as `${A:-${B:-$HOME/.x}}`; a + // single `[^}]*` regex only recognizes a prefix of that expression and + // makes preservation depend accidentally on where the rewritten text sits. + while (searchFrom < content.length) { + const start = content.indexOf('${', searchFrom); + if (start === -1) break; + const opener = /^\$\{[A-Za-z_][A-Za-z0-9_]*:-/.exec(content.slice(start)); + if (!opener) { + searchFrom = start + 2; + continue; + } + + let depth = 1; + let end = start + opener[0].length; + while (end < content.length && depth > 0) { + if (content.startsWith('${', end)) { + depth += 1; + end += 2; + continue; + } + if (content[end] === '}') depth -= 1; + end += 1; + } + if (depth !== 0) { + searchFrom = start + 2; + continue; + } + + masked += content.slice(copiedThrough, start); + preserved.push(content.slice(start, end)); + masked += `@@GSD4377:${preserved.length - 1}@@`; + copiedThrough = end; + searchFrom = end; + } + masked += content.slice(copiedThrough); + return rewrite(masked).replace(/@@GSD4377:(\d+)@@/g, (_m, i) => preserved[Number(i)]); +} + function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, attribution = undefined) { + // #4377: with a project-relative prefix, run the whole substitution body + // with `${VAR:-default}` shell defaults masked out, so the launcher shim + // keeps its absolute fallbacks. A no-op for the absolute (default) prefix. + if (isRelativePathPrefix(pathPrefix)) { + return withShellDefaultsPreserved( + content, + (masked) => _applyRuntimeRewritesInner(masked, runtime, pathPrefix, isGlobal, attribution), + ); + } + return _applyRuntimeRewritesInner(content, runtime, pathPrefix, isGlobal, attribution); +} + +function _applyRuntimeRewritesInner(content, runtime, pathPrefix, isGlobal = false, attribution = undefined) { const dirName = getDirName(runtime); const normalizedPathPrefix = pathPrefix.replace(/\/$/, ''); @@ -3533,7 +3739,9 @@ function rewriteStagedSkillBodies(stagedDir, opts) { const isGlobal = isGlobalScope(scope); const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite const isWindowsHost = platform === 'win32'; - const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir }); + // #4377: localDirName lets a local install emit a project-relative prefix + // when opted in; ignored for a global install and when the opt-in is off. + const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir, localDirName: localIncludeDirName(runtime) }); const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined; applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution); @@ -3585,7 +3793,9 @@ function rewriteStagedCommandBodies(stagedDir, opts) { const isGlobal = isGlobalScope(scope); const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite const isWindowsHost = platform === 'win32'; - const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir }); + // #4377: localDirName lets a local install emit a project-relative prefix + // when opted in; ignored for a global install and when the opt-in is off. + const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir, localDirName: localIncludeDirName(runtime) }); const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined; return applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution); @@ -3636,6 +3846,22 @@ function normalizeAgentBodyForRuntime(content: string, runtime: string, cmdNames */ function applyAgentPathRewrites(content: string, runtime: string, pathPrefix: string): string { if (_hostBehaviors(runtime).noPathRewrite === true) return content; + // #4377: the agents pipeline is the third emit path that substitutes this + // prefix (skills/commands via _applyRuntimeRewrites, the gsd-core spec tree + // via copyWithPathReplacement, and here). All three carry the same guard: + // with a project-relative prefix, `${VAR:-default}` shell defaults are + // masked out so the runtime launcher keeps its absolute fallbacks. A no-op + // for the absolute prefix. See withShellDefaultsPreserved for why. + if (isRelativePathPrefix(pathPrefix)) { + return withShellDefaultsPreserved( + content, + (masked) => applyAgentPathRewritesInner(masked, runtime, pathPrefix), + ); + } + return applyAgentPathRewritesInner(content, runtime, pathPrefix); +} + +function applyAgentPathRewritesInner(content: string, runtime: string, pathPrefix: string): string { const normalizedPathPrefix = pathPrefix.replace(/\/$/, ''); content = content.replace(/~\/\.claude\//g, pathPrefix); content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); @@ -3952,6 +4178,12 @@ export = { READONLY_AGENT_DISALLOWED_TOOLS, applyAgentFrontmatterExtensions, _computePathPrefix: computePathPrefix, + _withShellDefaultsPreserved: withShellDefaultsPreserved, + _isRelativePathPrefix: isRelativePathPrefix, + _relativeIncludesEnabled: relativeIncludesEnabled, + _projectRelativePrefix: projectRelativePrefix, + _projectRelativePrefixFromProjectRoot: projectRelativePrefixFromProjectRoot, + _localIncludeDirName: localIncludeDirName, _restoreClaudeGlobalAtRefTilde: restoreClaudeGlobalAtRefTilde, _applyRuntimeRewrites, _stampNonClaudeRuntimeDefaults, diff --git a/src/runtime-artifact-install-plan.cts b/src/runtime-artifact-install-plan.cts index 4991b55a2..39b06e9c5 100644 --- a/src/runtime-artifact-install-plan.cts +++ b/src/runtime-artifact-install-plan.cts @@ -79,12 +79,21 @@ interface ComputePathPrefixOpts { isWindowsHost: boolean; resolvedTarget: string; homeDir: string; + /** #4377: the runtime's `localConfigDir`, used only when the project-relative + * include style is opted in on a local install. Optional — an omitted value + * falls back to the absolute prefix, which is the pre-#4377 behavior. */ + localDirName?: string; + /** #4377: explicit opt-in override. Defaults to `GSD_RELATIVE_INCLUDES === '1'` + * inside `_computePathPrefix`; present here so tests can drive both arms + * without mutating the environment. */ + projectRelative?: boolean; } interface RuntimeArtifactConversionExports { rewriteStagedSkillBodies: (stagedDir: string, opts: RewriteOpts) => string | void; rewriteStagedCommandBodies: (stagedDir: string, opts: RewriteOpts) => string | void; _computePathPrefix: (opts: ComputePathPrefixOpts) => string; + _localIncludeDirName: (runtime: string) => string | undefined; } interface PlanItem { @@ -211,7 +220,9 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan const isGlobal = isGlobalScope(scope); const isOpencode = layout.runtime === 'opencode'; const isWindowsHost = (platform ?? process.platform) === 'win32'; - const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir }); + // #4377: descriptor-derived local dir name, so an opted-in local install + // emits a project-relative prefix instead of this checkout's absolute path. + const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir, localDirName: conversionExports._localIncludeDirName(layout.runtime) }); const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined; // #2875 Part 2 (row I1): layout.configDir IS the install root the inline // agent loop called `targetDir` — same value, same resolution. diff --git a/src/surface.cts b/src/surface.cts index ac4bdf300..4ab9b4f56 100644 --- a/src/surface.cts +++ b/src/surface.cts @@ -73,6 +73,18 @@ import retiredArtifactCleanup = require('./retired-artifact-cleanup.cjs'); const { assertDestWithinConfigHome } = runtimeArtifactInstallPlan; const SURFACE_FILE_NAME = '.gsd-surface.json'; +const INSTALL_MANIFEST_FILE_NAME = 'gsd-file-manifest.json'; + +/** Read only the installer-owned relative-include style, failing closed. */ +function readRelativeIncludePrefix(runtimeConfigDir: string): string | undefined { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(path.join(runtimeConfigDir, INSTALL_MANIFEST_FILE_NAME), 'utf8')); + const prefix = (parsed as Record)?.['relativeIncludePrefix']; + return typeof prefix === 'string' ? prefix : undefined; + } catch { + return undefined; + } +} // --------------------------------------------------------------------------- // Types @@ -409,7 +421,11 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map { }); }); +// --------------------------------------------------------------------------- +// #4377: project-relative includes for local installs +// --------------------------------------------------------------------------- + +describe('#4377 _computePathPrefix — project-relative local includes', () => { + // This block sits inside the enh-1511 fold, whose scope has its own + // `conversion` binding and does NOT see the outer file's + // `runtimeNamePolicy` (that one belongs to the fold that closed above). + // require() is cached, so this is a lookup, not a second load. + const namePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); + // A local install baked the install-time absolute path into every generated + // @ include. Worked from several git worktrees, that means each worktree + // runs its own engine but reads its workflow prose out of ONE checkout — + // and updating that checkout breaks every other worktree at once, with no + // way to stage it. The relative form lets each worktree read its own copy. + // + // Every arm below passes `projectRelative` explicitly rather than leaning on + // the environment default, so these assertions cannot flip on an ambient + // GSD_RELATIVE_INCLUDES leaking in from the runner. + const prefix = (over) => conversion._computePathPrefix({ + isGlobal: false, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/project/.cursor', + homeDir: '/home/u', + ...over, + }); + + test('opted in, a local install emits the descriptor dir, not the checkout path', () => { + assert.equal(prefix({ projectRelative: true, localDirName: '.cursor' }), '.cursor/'); + }); + + test('opted OUT, a local install is byte-identical to the pre-#4377 behavior', () => { + // The default must not change for the single-checkout majority. + assert.equal(prefix({ projectRelative: false, localDirName: '.cursor' }), '/project/.cursor/'); + }); + + test('a GLOBAL install ignores the opt-in entirely', () => { + // The $HOME-shorthand branch is the global contract and #4377 does not + // touch it — a relative include in a global install would resolve against + // whatever project the user happens to be sitting in. + assert.equal( + conversion._computePathPrefix({ + isGlobal: true, isOpencode: false, isWindowsHost: false, + resolvedTarget: '/home/u/.cursor', homeDir: '/home/u', + projectRelative: true, localDirName: '.cursor', + }), + '$HOME/.cursor/', + ); + }); + + test('a nested descriptor dir survives as a nested relative prefix', () => { + assert.equal( + prefix({ projectRelative: true, localDirName: '.config/opencode', resolvedTarget: '/project/.config/opencode' }), + '.config/opencode/', + ); + }); + + test('an explicit local target derives its prefix from the resolved target, not the runtime default', () => { + assert.equal( + prefix({ projectRelative: true, projectRoot: '/project', resolvedTarget: '/project/.custom/claude', localDirName: '.claude' }), + '.custom/claude/', + ); + }); + + test('a Windows-style descriptor value is normalized to POSIX', () => { + // The prefix is substituted into markdown @-references, which are POSIX + // universally — a backslash here leaks into shipped content (#1615). + assert.equal(prefix({ projectRelative: true, localDirName: '.claude\\nested' }), '.claude/nested/'); + }); + + test('a trailing slash in the descriptor does not double up', () => { + assert.equal(prefix({ projectRelative: true, localDirName: '.cursor/' }), '.cursor/'); + }); + + // ── fail-safe arms: anything unexpressible falls back to absolute ────────── + // A wrong-but-absolute include still points at a real file. A wrong RELATIVE + // one silently resolves against whatever the reader's cwd happens to be, + // which is a worse failure than the one being fixed. + for (const [label, localDirName] of [ + ['the no-local-config-dir sentinel (vscode)', namePolicy.NO_LOCAL_CONFIG_DIR_SENTINEL], + ['an absolute descriptor value', '/etc/gsd'], + ['a Windows-absolute descriptor value', 'C:/gsd'], + ['a value climbing out of the project', '../outside'], + ['a value climbing out of the project mid-path', 'nested/../../outside'], + ['a bare ..', '..'], + ['an empty value', ''], + ['a missing value', undefined], + ]) { + test(`falls back to the absolute prefix for ${label}`, () => { + assert.equal(prefix({ projectRelative: true, localDirName }), '/project/.cursor/'); + }); + } +}); + +describe('#4377 _relativeIncludesEnabled — the opt-in is off unless asked for', () => { + test('reads GSD_RELATIVE_INCLUDES=1 from the injected environment', () => { + assert.equal(conversion._relativeIncludesEnabled({ GSD_RELATIVE_INCLUDES: '1' }), true); + }); + + test('anything other than the exact string 1 is off', () => { + // No truthiness coercion: 'true'/'0'/'' must not silently opt a user in. + for (const value of ['true', 'yes', '0', '', 'TRUE', ' 1']) { + assert.equal(conversion._relativeIncludesEnabled({ GSD_RELATIVE_INCLUDES: value }), false, `value=${JSON.stringify(value)}`); + } + }); + + test('an absent variable is off', () => { + assert.equal(conversion._relativeIncludesEnabled({}), false); + }); + + test('the opt-in is true for exactly the string 1 over arbitrary JSON values', () => { + fc.assert(fc.property(fc.jsonValue(), (value) => { + assert.equal( + conversion._relativeIncludesEnabled({ GSD_RELATIVE_INCLUDES: value }), + value === '1', + ); + })); + }); +}); + +describe('#4377 project-relative prefix properties', () => { + const segment = fc.array( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789_-'), + { minLength: 1, maxLength: 12 }, + ).map((chars) => chars.join('')); + + test('safe descriptor segments normalize to one POSIX prefix', () => { + fc.assert(fc.property( + fc.array(segment, { minLength: 1, maxLength: 5 }), + fc.constantFrom('/', '\\'), + fc.boolean(), + (segments, separator, trailingSlash) => { + const localDirName = segments.join(separator) + (trailingSlash ? separator : ''); + assert.equal(conversion._projectRelativePrefix(localDirName), `${segments.join('/')}/`); + }, + )); + }); + + test('a traversal segment is rejected at every generated depth', () => { + fc.assert(fc.property( + fc.array(segment, { maxLength: 4 }), + fc.array(segment, { maxLength: 4 }), + (before, after) => { + assert.equal(conversion._projectRelativePrefix([...before, '..', ...after].join('/')), ''); + }, + )); + }); +}); + +test('#4377: a project-root local target falls back to absolute includes', () => { + assert.equal(conversion._localIncludeDirName('cline'), undefined); + assert.equal(conversion._localIncludeDirName('claude'), '.claude'); +}); + +describe('#4377 relative rewrites preserve every runtime launcher shell default', () => { + test('the shared mask preserves a complete nested shell default as one unit', () => { + const nested = '${OUTER:-${INNER:-$HOME/.claude}/gsd-core}'; + const input = `outside=$HOME/.claude inside=${nested}`; + const rewritten = conversion._withShellDefaultsPreserved( + input, + (body) => body.replace(/\$HOME\/\.claude/g, '.claude'), + ); + assert.equal(rewritten, `outside=.claude inside=${nested}`); + }); + + test('all emitted runtime conversions leave ${VAR:-default} probes verbatim', () => { + const launcher = fs.readFileSync( + path.join(__dirname, '..', 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh'), + 'utf8', + ); + const defaults = []; + for (let start = launcher.indexOf('${'); start !== -1; start = launcher.indexOf('${', start + 2)) { + let depth = 1; + let end = start + 2; + while (end < launcher.length && depth > 0) { + if (launcher.startsWith('${', end)) { + depth += 1; + end += 2; + } else { + if (launcher[end] === '}') depth -= 1; + end += 1; + } + } + if (depth !== 0) break; + const expansion = launcher.slice(start, end); + if (/^\$\{[A-Za-z_][A-Za-z0-9_]*:-/.test(expansion)) defaults.push(expansion); + start = end - 2; + } + assert.ok(defaults.length > 0, 'the launcher fixture must contain shell-default probes'); + + for (const runtime of ['claude', ...conversion.NON_CLAUDE_RUNTIMES]) { + const rewritten = conversion._applyRuntimeRewrites( + launcher, + runtime, + `.${runtime}/`, + ); + for (const shellDefault of defaults) { + assert.ok( + rewritten.includes(shellDefault), + `${runtime} relative rewrite must preserve ${shellDefault}`, + ); + } + } + }); +}); + // --------------------------------------------------------------------------- // Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir // on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory. diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 2f31a5db0..1511e8ae0 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -8204,3 +8204,223 @@ describe('#607 cleanupLegacyGsdCc: exported helper unit tests', () => { }); }); } + +// --------------------------------------------------------------------------- +// #4377 — --relative-includes: a local install must not bake the checkout path +// --------------------------------------------------------------------------- +// +// The unit arms in tests/install-runtime-artifacts.test.cjs pin +// _computePathPrefix itself. This is the end-to-end proof: the prefix has to +// travel from the CLI flag, through the install engine, through the rewrite +// pass, and land in the bytes on disk. A pure-function test alone would pass +// happily while any one of those six seams kept computing the absolute form. +describe('#4377: --relative-includes emits project-relative @ includes for a local install', () => { + // `before`/`after` are not in this file's tail scope (the earlier folds each + // bring their own); pull them from node:test directly rather than relying on + // whatever binding happens to be visible here. + const { before: beforeAll, after: afterAll } = require('node:test'); + const { installSpawnEnv } = require('./helpers.cjs'); + const { throwIfFailed } = require('./helpers/git-fixture.cjs'); + const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + const installPath = path.join(__dirname, '..', 'bin', 'install.js'); + + // Two independent reasons the raw temp root does not appear in emitted + // content, both of which would make the negative arms pass VACUOUSLY — + // "nothing references the checkout" is trivially true when the string being + // searched for cannot occur: + // + // 1. The emitted prefix is POSIX-normalized by design, because it is + // substituted into markdown @-references, which use forward slashes + // universally; a backslash there would leak into shipped content + // (#1615). On Windows the roots arrive as `D:\a\...`. + // 2. On macOS `os.tmpdir()` yields `/var/folders/...`, but `/var` is a + // symlink to `/private/var`, and the installer resolves it — so the + // emitted content carries `/private/var/folders/...` while + // `fs.mkdtempSync` handed us the unresolved spelling. + // + // Every comparison therefore goes through both spellings. + const toPosix = (p2) => p2.replace(/\\/g, '/'); + // + // LONGEST FIRST, and that ordering is load-bearing. `/var/folders/…/X` is a + // SUBSTRING of `/private/var/folders/…/X`, so stripping the short spelling + // first matches inside the long one and leaves the `/private` prefix glued + // to what followed it: `@/private/var/…/X/.claude/gsd-core/…` normalizes to + // `@/private.claude/gsd-core/…`, a string that appears in neither install. + // Removing the long form first consumes the whole occurrence, and the short + // form then has nothing left to match. + const rootSpellings = (root) => { + const forms = new Set([toPosix(root)]); + try { forms.add(toPosix(fs.realpathSync(root))); } catch { /* root already gone */ } + return [...forms].sort((a, b) => b.length - a.length); + }; + const mentionsRoot = (content, root) => rootSpellings(root).some((r) => content.includes(r)); + + // Self-contained runner: the file's other runInstall helpers live inside + // folded blocks and are not in scope here. #3156's sandboxed HOME still + // applies — the installer writes /.gsd/defaults.json via os.homedir() + // directly, which no env scrub reaches. + const install = (cwd, args) => { + const env = installSpawnEnv(); + delete env.GSD_TEST_MODE; + // The relative-includes opt-in is dual-sourced (flag OR env), so an + // ambient GSD_RELATIVE_INCLUDES would silently opt the CONTROL arm in and + // make this suite prove nothing. Scrub it and let the flag speak. + delete env.GSD_RELATIVE_INCLUDES; + const r = runNode([installPath, ...args], { + cwd, env, timeoutMs: INSTALL_TIMEOUT_MS, + }); + throwIfFailed(r, `node ${installPath} ${args.join(' ')}`); + }; + + let absDir; + let relDir; + + beforeAll(() => { + absDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4377-abs-')); + relDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4377-rel-')); + install(absDir, ['--claude', '--local', '--no-sdk']); + install(relDir, ['--claude', '--local', '--no-sdk', '--relative-includes']); + }); + + afterAll(() => { + cleanup(absDir); + cleanup(relDir); + }); + + // Walk the WHOLE installed tree, not just commands/. The issue measured 145 + // affected files across commands, workflows and references on a real repo — + // a commands-only assertion would have called this fixed while 124 workflow + // and reference files still carried the baked path. + const allBodies = (root, base = path.join(root, '.claude')) => { + const out = []; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { walk(full); continue; } + if (!entry.isFile()) continue; + out.push({ rel: path.relative(base, full), content: fs.readFileSync(full, 'utf-8') }); + } + }; + if (fs.existsSync(base)) walk(base); + return out; + }; + + test('the default install still bakes the absolute checkout path (unchanged behavior)', () => { + // The control. Without it, a change that broke the absolute form entirely + // would satisfy every assertion below and look like a fix. + const carriers = allBodies(absDir).filter((f) => mentionsRoot(f.content, absDir)); + assert.ok( + carriers.length > 0, + '#4377 is opt-in: the default local install must keep emitting absolute includes', + ); + }); + + test('with the flag, NOTHING in the installed tree references the checkout it came from', () => { + const offenders = allBodies(relDir).filter((f) => mentionsRoot(f.content, relDir)).map((f) => f.rel); + assert.deepEqual(offenders, [], 'no installed file may reference the checkout it was installed from'); + }); + + test('with the flag, the includes are project-relative and still resolve to gsd-core', () => { + // Not merely "absent" — an implementation that stripped the prefix would + // pass the arm above while shipping includes that point nowhere. + const withRelative = allBodies(relDir).filter((f) => f.content.includes('@.claude/gsd-core/')); + assert.ok(withRelative.length > 0, 'expected @.claude/gsd-core/ includes in the relative install'); + }); + + test('the local install manifest persists the relative style for a later surface apply', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(relDir, '.claude', 'gsd-file-manifest.json'), 'utf8')); + assert.equal(manifest.relativeIncludePrefix, '.claude/'); + }); + + test('a project-root runtime falls back to absolute includes instead of inventing its descriptor directory', (t) => { + // Cline declares localTargetIsProjectRoot: local agents live in `agents/`, + // not `.cline/agents/`. A relative `.cline/` prefix would therefore point + // at a directory the install never creates. This crosses the real install + // plan and agent-rewrite seam; testing _localIncludeDirName alone would not + // prove every production consumer uses the guard. + const clineDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4377-cline-')); + t.after(() => cleanup(clineDir)); + install(clineDir, ['--cline', '--local', '--no-sdk', '--relative-includes']); + + const agents = allBodies(clineDir, path.join(clineDir, 'agents')); + assert.ok(agents.length > 0, 'the local Cline install must emit agents at the project root'); + const descriptorRelative = agents.filter((f) => f.content.includes('@.cline/gsd-core/')).map((f) => f.rel); + assert.deepEqual(descriptorRelative, [], 'Cline has no .cline/ local target, so its includes must not use one'); + const absoluteFallback = agents.filter((f) => rootSpellings(clineDir) + .some((root) => f.content.includes(`@${root}/gsd-core/`))); + assert.ok(absoluteFallback.length > 0, 'an unrepresentable project-relative target must retain the safe absolute prefix'); + }); + + test('the launcher shim keeps ABSOLUTE shell defaults even with the flag', () => { + // The one place a relative prefix would make things worse. The runtime + // launcher probes gsd-tools through `${CLAUDE_CONFIG_DIR:-$HOME/.claude}`; + // that is a shell word expansion, not an @ include, and a bare `.claude` + // there resolves against the shell's cwd rather than the project. Trading + // an include that points at the wrong checkout for a path that points at + // nothing would not be a fix. + const carriers = allBodies(relDir).filter((f) => f.content.includes('CLAUDE_CONFIG_DIR:-')); + assert.ok(carriers.length > 0, 'expected the launcher snippet in the installed tree'); + const offenders = carriers + .filter((f) => !/CLAUDE_CONFIG_DIR:-\$HOME\/\.claude\}/.test(f.content)) + .map((f) => f.rel); + assert.deepEqual(offenders, [], 'the launcher fallback must stay $HOME-absolute'); + }); + + test('no masking sentinel survives into the installed tree', () => { + // The shell-default guard masks with a literal token before rewriting and + // restores after. A restore that missed would ship that token as content. + // gsd-core/bin/lib/ carries the implementation itself, so exclude it. + const leaked = allBodies(relDir) + .filter((f) => !f.rel.startsWith(path.join('gsd-core', 'bin', 'lib'))) + .filter((f) => f.content.includes('@@GSD4377:')) + .map((f) => f.rel); + assert.deepEqual(leaked, [], 'the shell-default mask must be fully restored'); + }); + + test('the two installs differ ONLY where the flag is meant to change things', () => { + // The strongest arm. Normalize the absolute install into what the relative + // one should be — undo the two deliberate differences — and the trees must + // then be byte-identical. Anything ELSE the flag touched surfaces here + // instead of going unnoticed. + const abs = allBodies(absDir); + const rel = new Map(allBodies(relDir).map((f) => [f.rel, f.content])); + assert.ok(abs.length > 0, 'the control install must produce files'); + const mismatched = []; + for (const { rel: name, content } of abs) { + if (!rel.has(name)) { mismatched.push(`${name} (missing from relative install)`); continue; } + // Both manifests record per-file absolute paths, which legitimately + // differ between two different install roots. + if (name === 'gsd-file-manifest.json' || name === 'gsd-install-state.json') continue; + let normalized = content; + for (const absRoot of rootSpellings(absDir)) { + normalized = normalized + // (1) shell defaults: the absolute install rewrote them to its own + // root; the relative install left them at $HOME. + .split(`:-${absRoot}/.claude}`).join(':-$HOME/.claude}') + // (2) the include prefix itself, both the slash form and the bare + // trailing form the word-boundary passes emit. + .split(`${absRoot}/.claude/`).join('.claude/') + .split(`${absRoot}/.claude`).join('.claude'); + } + if (normalized !== rel.get(name)) mismatched.push({ name, normalized, actual: rel.get(name) }); + } + // Report the FIRST divergence as text, not just a list of filenames. A + // bare file list says a difference exists somewhere in 236 files and + // leaves the reader to guess which bytes — and on a platform I cannot + // reproduce locally, guessing is what turns one CI round-trip into four. + const detail = mismatched.length === 0 ? '' : (() => { + const { name, normalized, actual } = mismatched[0]; + let at = 0; + while (at < normalized.length && at < actual.length && normalized[at] === actual[at]) at += 1; + const from = Math.max(0, at - 60); + return `\nfirst divergence in ${name} at offset ${at}:\n` + + ` absolute(normalized): ${JSON.stringify(normalized.slice(from, at + 120))}\n` + + ` relative(actual): ${JSON.stringify(actual.slice(from, at + 120))}\n` + + `(${mismatched.length} file(s) differ: ${mismatched.slice(0, 8).map((m) => m.name).join(', ')}${mismatched.length > 8 ? ', …' : ''})`; + })(); + assert.deepEqual( + mismatched.map((m) => m.name), [], + `the flag must change the include prefix, and nothing beyond it${detail}`, + ); + }); +});