From 72819a4616ebab20c1a7afdc2c446dccf2fe0689 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 21 Aug 2026 03:05:58 -0400 Subject: [PATCH] fix(#3031): opt-in reclaim of GSD hooks orphaned in ~/.kimi (#3731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#3031): failing-first coverage for opt-in ~/.kimi legacy reclaim Drives the user-reachable installer surface against a sandbox HOME seeded with the pre-#2755 wreckage: a GSD [[hooks]] block, hooks bundle and CommonJS marker orphaned in ~/.kimi by a --kimi-code install. Covers the reclaim itself plus the four guards the diagnosis identified as negative space: opt-in only (no flag, no deletion), user-authored TOML and hook files preserved, a --kimi install never reclaiming its own root, and the KIMI_SHARE_DIR/KIMI_CODE_HOME collision where both roots resolve to one directory. Adds a fast-check property that stripping the block never destroys user content. Red until --reclaim-kimi-legacy exists. Refs #3031 * fix(#3031): opt-in reclaim of GSD hooks orphaned in ~/.kimi A --kimi-code install older than 1.10.0 wrote its GSD [[hooks]] block, hook bundle and CommonJS marker into Kimi CLI's ~/.kimi. #2755 fixed the destination but could not reclaim what the old bug already wrote: the stale block is byte-identical to a legitimate Kimi CLI one — both runtimes render the same bytes for the same root, since the command paths derive from the hooks root, not the runtime — so no inspection can tell litter from a working install. Cleanup is therefore opt-in. `--reclaim-kimi-legacy` on a --kimi-code install removes GSD's own artifacts from the legacy root; without it nothing is touched, so a dual-product machine keeps Kimi CLI's hooks and #2755's acceptance criterion holds. Extracts the uninstall path's removal sequence into reclaimKimiHooksRoot() and drives both callers through it, so the reclaim removes precisely what a real uninstall removes rather than a hand-copied second implementation. Guards the wrong-runtime case (a --kimi install would delete its own hooks) and the KIMI_SHARE_DIR/KIMI_CODE_HOME collision where both roots resolve to one directory. Also corrects two pre-#2755 leftovers in the same surface that told users to run `--kimi --config-dir ~/.kimi-code` — the form that produces this very defect, since --config-dir moves only the skills root — and adds the missing --kimi-code entry to the installer's own help. Regression coverage folded into tests/kimi-upgrades.test.cjs beside the #2755 cases, per the regression-test-naming lint. Fixes #3031 * fix(#3031): never reclaim ~/.kimi when this run also installs kimi Found by the isolated adversarial review pass and independently while tracing --all ordering, then reproduced. selectRuntimesFromArgs orders 'kimi' before 'kimi-code' in both --all and an explicit --kimi --kimi-code, and installAllRuntimes installs in that order. So --all --reclaim-kimi-legacy installed a fresh, legitimate Kimi CLI hooks block into ~/.kimi and then deleted it moments later from the kimi-code leg — exiting 0 and reporting success while leaving the user with no Kimi CLI hooks at all. The collision guard could not catch it: kimi-code's own root is ~/.kimi-code, a genuinely different directory. The flag asserts "I only use Kimi Code"; installing kimi in the same invocation falsifies that, so the reclaim is skipped with a notice. Also hardens the collision guard itself. It compared path.resolve strings, which returns false for two spellings of ONE directory — measured, not assumed: a symlinked alias and a case variant on a case-insensitive filesystem both compared unequal, so the guard would not have fired and the install would have deleted its own freshly-written hooks. isSameDirectory now compares directories via resolve, then dev+ino identity, then realpath. Regression tests for all three cases; the two alias tests probe the real filesystem and t.skip() where the alias cannot exist. Refs #3031 * docs(#3031): reattach reclaimKimiHooksRoot's JSDoc to its own function Inserting isSameDirectory anchored on the function name, which placed the helper between reclaimKimiHooksRoot's doc block and the function it documents. isSameDirectory ended up with two stacked doc blocks above it and reclaimKimiHooksRoot with none. Refs #3031 * fix(#3031): warn when --reclaim-kimi-legacy cannot apply The flag only acts inside the kimi-code GLOBAL install branch. Passed with any other runtime, or with --local, it was consumed in silence: exit 0, no cleanup, no message. For a cleanup the user explicitly asked for, silence is indistinguishable from "it ran and found nothing". The scope warning is raised at argument-resolution time rather than inside install(). kimi-code declares hostBehaviors.localInstallDeferred, so install() returns early at the deferral check long before the kimi-hooks-toml branch — a guard placed there is unreachable, which is both dead code and a linted drift shape in this repo. Verified reachable by spawning the real installer. Neither case is a hard error: the flag stays composable with --all, where it is legitimately inert for the other seventeen runtimes. Refs #3031 * docs(#3031): document every case where --reclaim-kimi-legacy skips Refs #3031 * fix(#3031): resolve local config dirs from RUNTIME_META alone in the install harness The remote runner surfaced this: the #3031 warning test drives a local kimi-code install and died with "The path argument must be of type string. Received undefined". runMinimalInstall carried a SECOND, hand-maintained local-dir map beside RUNTIME_META, and it had drifted — four runtimes present in RUNTIME_META (hermes, kimi, kimi-code, zcode) were missing from it, so scope:'local' for any of them resolved path.join(root, undefined) and threw a bare TypeError naming neither the runtime nor the map at fault. #3023 had already hit exactly this for pi and fixed it by adding one more entry, which left the divergence itself in place for the next runtime to rediscover. Local scope now reads RUNTIME_META.localDir, the same table the global branch already reads, with the same loud named error the global branch raises. Parity verified for all 14 previously-supported runtimes: every one resolves to a byte-identical configDir. cline keeps its ternary — its local artifacts land at the project root itself, which is a real exception, not a directory name. Guarded in golden-parity-single-source.test.cjs beside the buildParityManifest anti-divergence test, and both arms of that guard were proven able to fail. Refs #3031 * chore(#3031): backfill changeset pr number --------- Co-authored-by: sim --- .changeset/sharp-deer-forage.md | 5 + bin/install.js | 260 ++++++++++++++----- docs/how-to/install-on-your-runtime.md | 10 +- docs/migration/kimi-to-kimi-code.md | 28 +- tests/golden-parity-single-source.test.cjs | 30 +++ tests/helpers/install-shared.cjs | 29 ++- tests/kimi-upgrades.test.cjs | 284 ++++++++++++++++++++- 7 files changed, 561 insertions(+), 85 deletions(-) create mode 100644 .changeset/sharp-deer-forage.md diff --git a/.changeset/sharp-deer-forage.md b/.changeset/sharp-deer-forage.md new file mode 100644 index 000000000..34ffa6341 --- /dev/null +++ b/.changeset/sharp-deer-forage.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3731 +--- +**Orphaned GSD hooks in `~/.kimi` can now be reclaimed** — a `--kimi-code` install older than 1.10.0 wrote its hooks block, hook bundle and CommonJS marker into Kimi CLI's `~/.kimi` instead of Kimi Code's own root, and upgrading stranded those artifacts with no path to remove them. Adding `--reclaim-kimi-legacy` to a `--kimi-code` install now clears them; it stays opt-in because the stale block is byte-identical to a legitimate Kimi CLI one, so an automatic cleanup could not tell the two apart. (#3031) diff --git a/bin/install.js b/bin/install.js index 9ad864406..f96fe9829 100755 --- a/bin/install.js +++ b/bin/install.js @@ -812,6 +812,17 @@ 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'); +// #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 +// the same bytes for the same root, since the command paths derive from the +// hooks root and not from the runtime — so no inspection can tell "litter GSD +// wrote for kimi-code" from "Kimi CLI's working hooks". Cleaning unasked would +// break #2755's own acceptance criterion ("Uninstalling GSD hooks for one +// runtime does not touch or remove the other runtime's hooks") for anyone with +// both products installed. The user, who knows which products they run, is the +// only party that can decide — so they ask for it explicitly. +const hasReclaimKimiLegacy = args.includes('--reclaim-kimi-legacy'); // --profile= or --profile=, (composable); mutually exclusive with --minimal const _profileArgRaw = (() => { for (const arg of args) { @@ -911,6 +922,21 @@ function disambiguateKimiVariant(runtimes) { return notices; } +// #3031: `--reclaim-kimi-legacy` only ever acts inside the kimi-code GLOBAL +// install branch. Say so when it cannot act, rather than exiting 0 having +// silently done nothing: the user asked for a cleanup, and silence is +// indistinguishable from "it ran and found nothing". Not a hard error — it +// stays composable with `--all`, where it is legitimately inert for the other +// seventeen runtimes. +if (hasReclaimKimiLegacy && !selectedRuntimes.includes('kimi-code')) { + console.error(`${yellow}⚠ --reclaim-kimi-legacy ignored${reset} — it applies only to a --kimi-code install; nothing in ~/.kimi was touched.`); +} else if (hasReclaimKimiLegacy && hasLocal) { + // Scope, checked HERE rather than inside install(): kimi-code declares + // hostBehaviors.localInstallDeferred, so install() returns early long before + // the kimi-hooks-toml branch — a warning placed there would be unreachable. + console.error(`${yellow}⚠ --reclaim-kimi-legacy ignored${reset} — the legacy root is a global location; re-run with --global to reclaim it.`); +} + if (selectedRuntimes.includes('kimi') || selectedRuntimes.includes('kimi-code')) { const kimiNotices = disambiguateKimiVariant(selectedRuntimes); for (const notice of kimiNotices) { @@ -1105,7 +1131,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}--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}-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 (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\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 CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\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 Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\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}-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 (for WSL/Docker bind-mount 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`); process.exit(0); } @@ -7862,6 +7888,133 @@ function validateHookFields(settings) { */ const GSD_UNINSTALL_HOOKS = [..._HOOKS_TO_COPY, 'gsd-check-update.cmd']; +/** + * Whether two paths denote the SAME directory — used to stop a reclaim from + * deleting the very root the current install just wrote (#3031). + * + * A plain `path.resolve` comparison is not enough here, because both roots come + * from user-controlled env vars (`KIMI_SHARE_DIR`, `KIMI_CODE_HOME`) and two + * different strings routinely name one directory: + * - case-insensitive filesystems (macOS, Windows): `~/Kimi` vs `~/kimi` + * - symlinks / bind mounts: `~/link-to-kimi` vs the real target + * Getting this wrong is not cosmetic — it is the difference between skipping a + * reclaim and deleting a live install's own hooks. + * + * Strategy, cheapest-first: string equality after `resolve`, then identity by + * `dev`+`ino` (definitive when both exist and the platform reports them), then + * `realpath` string equality (resolves symlinks AND canonicalizes case). Any + * rung answering "same" wins; a path that does not exist cannot be the root we + * just wrote, so a failed stat simply falls through. + * + * @returns {boolean} true only when both paths are proven to be one directory. + */ +function isSameDirectory(a, b) { + if (path.resolve(a) === path.resolve(b)) return true; + try { + const sa = fs.statSync(a); + const sb = fs.statSync(b); + // `ino` is 0 on some Windows filesystems; only trust a positive match. + if (sa.ino && sb.ino && sa.dev === sb.dev && sa.ino === sb.ino) return true; + } catch (_) { /* one side missing — fall through to realpath */ } + try { + return fs.realpathSync.native(a) === fs.realpathSync.native(b); + } catch (_) { + return false; + } +} + +/** + * Remove every GSD-owned artifact from a Kimi hooks root (`~/.kimi` for kimi, + * `~/.kimi-code` for kimi-code — resolveKimiHooksTomlDir, #2755): the managed + * `[[hooks]]` block in the native config.toml, the hook scripts, hooks/lib/, + * and the CommonJS marker at both its current (hooks/) and pre-#2544 (root) + * locations. + * + * This root is Kimi's own native config home — SHARED space that may hold the + * user's real config.toml, providers and their own scripts — so only exact + * GSD-owned filenames are removed and directories are pruned only when that + * removal leaves them empty. + * + * TWO callers, deliberately one implementation (#3031). `uninstall()` calls it + * for the runtime being uninstalled; the opt-in `--reclaim-kimi-legacy` path in + * `install()` calls it for the LEGACY `~/.kimi` root a pre-#2755 `--kimi-code` + * install orphaned. Duplicating this sequence for the second caller would be + * exactly the generative-divergence hazard the repo bans — the reclaim must + * remove precisely what a real uninstall removes, forever, by construction. + * + * @param {string} kimiHooksRoot - Absolute path to the Kimi hooks root. + * @returns {number} count of removal steps performed (0 when nothing matched). + */ +function reclaimKimiHooksRoot(kimiHooksRoot) { + let steps = 0; + const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml'); + const kimiHooksCleanup = removeKimiHooksToml(kimiHooksTomlPath); + if (kimiHooksCleanup.changed) { + steps++; + console.log(` ${green}✓${reset} Removed GSD hooks from ${kimiHooksTomlPath}`); + } + + // Kimi's shared hook scripts + CommonJS package.json marker are installed + // into this SAME ~/.kimi root (installSharedHooksBundle, install()'s + // kimi-hooks-toml branch) rather than under targetDir — mirror steps "4. + // Remove GSD hooks" / "5. Remove GSD package.json" below, but scoped to + // kimiHooksRoot. ~/.kimi is Kimi's own native config home (shared space — + // may hold the user's real config.toml/providers), so only the exact + // GSD-owned filenames are removed, and directories are pruned only if left + // empty by that removal. + const kimiHooksDir = path.join(kimiHooksRoot, 'hooks'); + if (fs.existsSync(kimiHooksDir)) { + let kimiHookCount = 0; + for (const hook of GSD_UNINSTALL_HOOKS) { + const hookPath = path.join(kimiHooksDir, hook); + if (fs.existsSync(hookPath)) { + fs.unlinkSync(hookPath); + kimiHookCount++; + } + } + if (kimiHookCount > 0) { + steps++; + console.log(` ${green}✓${reset} Removed ${kimiHookCount} GSD hooks from ${kimiHooksDir}`); + } + + const kimiHooksLibDir = path.join(kimiHooksDir, 'lib'); + if (fs.existsSync(kimiHooksLibDir)) { + let removedKimiLibFiles = 0; + for (const file of GSD_HOOK_LIB_FILES) { + try { + fs.unlinkSync(path.join(kimiHooksLibDir, file)); + removedKimiLibFiles++; + } catch (_) { /* best-effort */ } + } + try { fs.rmdirSync(kimiHooksLibDir); } catch (_) { /* not empty or other error — leave it */ } + if (removedKimiLibFiles > 0) { + steps++; + console.log(` ${green}✓${reset} Removed ${removedKimiLibFiles} hooks/lib/ helper(s) from ${kimiHooksLibDir}`); + } + } + + // #2544: the marker now lives inside kimi's hooks/ dir — remove it + // before the emptiness check below, or the dir would never prune. + if (removeCommonJsMarker(kimiHooksDir)) { + steps++; + console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksDir}`); + } + + try { + if (fs.readdirSync(kimiHooksDir).length === 0) fs.rmdirSync(kimiHooksDir); + } catch (_) { /* not empty — leave it */ } + } + + // Retire the pre-#2544 marker at kimi's root (~/.kimi), where the bundle + // used to write it. Exact content match — a user's own package.json in + // kimi's native config home is never touched. + if (removeCommonJsMarker(kimiHooksRoot)) { + steps++; + console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksRoot} (pre-#2544 marker)`); + } + return steps; +} + /** * Uninstall GSD from the specified directory for a specific runtime * Removes only GSD-specific files/directories, preserves user content @@ -8059,72 +8212,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { // cleanup can't be driven by anything under targetDir the way every other // hook surface above is. if (resolveInstallPlan(runtime).hooksSurface === 'kimi-hooks-toml') { - const kimiHooksRoot = resolveKimiHooksTomlDir({ runtime }); - const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml'); - const kimiHooksCleanup = removeKimiHooksToml(kimiHooksTomlPath); - if (kimiHooksCleanup.changed) { - removedCount++; - console.log(` ${green}✓${reset} Removed GSD hooks from ${kimiHooksTomlPath}`); - } - - // Kimi's shared hook scripts + CommonJS package.json marker are installed - // into this SAME ~/.kimi root (installSharedHooksBundle, install()'s - // kimi-hooks-toml branch) rather than under targetDir — mirror steps "4. - // Remove GSD hooks" / "5. Remove GSD package.json" below, but scoped to - // kimiHooksRoot. ~/.kimi is Kimi's own native config home (shared space — - // may hold the user's real config.toml/providers), so only the exact - // GSD-owned filenames are removed, and directories are pruned only if left - // empty by that removal. - const kimiHooksDir = path.join(kimiHooksRoot, 'hooks'); - if (fs.existsSync(kimiHooksDir)) { - let kimiHookCount = 0; - for (const hook of GSD_UNINSTALL_HOOKS) { - const hookPath = path.join(kimiHooksDir, hook); - if (fs.existsSync(hookPath)) { - fs.unlinkSync(hookPath); - kimiHookCount++; - } - } - if (kimiHookCount > 0) { - removedCount++; - console.log(` ${green}✓${reset} Removed ${kimiHookCount} GSD hooks from ${kimiHooksDir}`); - } - - const kimiHooksLibDir = path.join(kimiHooksDir, 'lib'); - if (fs.existsSync(kimiHooksLibDir)) { - let removedKimiLibFiles = 0; - for (const file of GSD_HOOK_LIB_FILES) { - try { - fs.unlinkSync(path.join(kimiHooksLibDir, file)); - removedKimiLibFiles++; - } catch (_) { /* best-effort */ } - } - try { fs.rmdirSync(kimiHooksLibDir); } catch (_) { /* not empty or other error — leave it */ } - if (removedKimiLibFiles > 0) { - removedCount++; - console.log(` ${green}✓${reset} Removed ${removedKimiLibFiles} hooks/lib/ helper(s) from ${kimiHooksLibDir}`); - } - } - - // #2544: the marker now lives inside kimi's hooks/ dir — remove it - // before the emptiness check below, or the dir would never prune. - if (removeCommonJsMarker(kimiHooksDir)) { - removedCount++; - console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksDir}`); - } - - try { - if (fs.readdirSync(kimiHooksDir).length === 0) fs.rmdirSync(kimiHooksDir); - } catch (_) { /* not empty — leave it */ } - } - - // Retire the pre-#2544 marker at kimi's root (~/.kimi), where the bundle - // used to write it. Exact content match — a user's own package.json in - // kimi's native config home is never touched. - if (removeCommonJsMarker(kimiHooksRoot)) { - removedCount++; - console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksRoot} (pre-#2544 marker)`); - } + removedCount += reclaimKimiHooksRoot(resolveKimiHooksTomlDir({ runtime })); } // 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup @@ -12064,6 +12152,44 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { if (kimiHooksResult.changed) { console.log(` ${green}✓${reset} Configured ${kimiHooksResult.entryCount} GSD hook(s) in ${kimiHooksTomlPath}`); } + + // #3031: opt-in reclaim of the pre-#2755 legacy root. Runs LAST in this + // branch so the kimi-code install above is already complete and durable — + // a reclaim can only ever remove, never leave the install half-written. + // + // Gated on `runtime === 'kimi-code'`: a `--kimi` install resolves this + // very same `~/.kimi` as its own hooks root, so reclaiming there would + // delete the hooks it just wrote. The flag is silently inert for kimi + // rather than an error — `--all` passes every runtime through this branch, + // and one opt-in flag must not fail an otherwise valid multi-runtime run. + // + // ALSO gated on kimi NOT being installed by this same invocation. The + // flag asserts "I only use Kimi Code"; `--all`, or an explicit `--kimi + // --kimi-code`, falsifies that outright. Both orderings put `kimi` BEFORE + // `kimi-code` (selectRuntimesFromArgs), so without this guard the run + // installs Kimi CLI's hooks and then deletes them moments later — the run + // reports success and the user is left with the very breakage the opt-in + // exists to prevent. Verified reproducible before this guard existed. + const kimiInstalledThisRun = selectedRuntimes.includes('kimi'); + if (hasReclaimKimiLegacy && runtime === 'kimi-code' && kimiInstalledThisRun) { + console.log(` ${dim}•${reset} Skipped --reclaim-kimi-legacy: this run also installs --kimi, so ${resolveKimiHooksTomlDir({ runtime: 'kimi' })} is a live Kimi CLI install`); + } else if (hasReclaimKimiLegacy && runtime === 'kimi-code') { + const legacyKimiRoot = resolveKimiHooksTomlDir({ runtime: 'kimi' }); + // Both roots honor their own env override (KIMI_SHARE_DIR / + // KIMI_CODE_HOME). A user who points both at ONE directory collapses + // "the legacy root" onto "the root this install just wrote", and an + // unguarded reclaim would delete its own output. isSameDirectory compares + // the DIRECTORIES, not the strings — case-insensitive filesystems and + // symlinked aliases both name one dir with two spellings. + if (isSameDirectory(legacyKimiRoot, kimiHooksRoot)) { + console.log(` ${dim}•${reset} Skipped --reclaim-kimi-legacy: ${legacyKimiRoot} is this install's own hooks root`); + } else { + const reclaimed = reclaimKimiHooksRoot(legacyKimiRoot); + console.log(reclaimed > 0 + ? ` ${green}✓${reset} Reclaimed ${reclaimed} orphaned GSD artifact group(s) from ${legacyKimiRoot} (pre-#2755)` + : ` ${dim}•${reset} No orphaned GSD artifacts found in ${legacyKimiRoot}`); + } + } } // ADR-1239 / #2100 Stage 2: Windsurf's own independent hooksSurface — diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 1d6a42b11..298414ce6 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -215,7 +215,15 @@ If your machine already uses `~/.agents/skills` and does not have `~/.config/age kimi --agent-file ~/.agents/agents/gsd.yaml ``` -Kimi also discovers user skills from the brand-specific `~/.kimi-code` directory. If your Kimi setup is already centered on `~/.kimi-code`, install there explicitly: +> **If you are on Kimi Code, use `--kimi-code`, not `--kimi` with a redirected config dir.** Since 1.10.0 (#2755) Kimi Code is its own runtime with its own hooks root: +> +> ```bash +> npx @opengsd/gsd-core@latest --kimi-code --global +> ``` +> +> `--config-dir` and `KIMI_CONFIG_DIR` select the *skills* root only. They do **not** move the native `config.toml` that carries GSD's `[[hooks]]` block — that root is chosen by the runtime (`~/.kimi` for `--kimi` via `KIMI_SHARE_DIR`, `~/.kimi-code` for `--kimi-code` via `KIMI_CODE_HOME`). Running `--kimi --config-dir ~/.kimi-code` therefore puts your skills under `~/.kimi-code` while the hooks still land in `~/.kimi` — the exact split that left orphaned hooks behind before 1.10.0. See [Migrating from `--kimi` to `--kimi-code`](../migration/kimi-to-kimi-code.md), which also covers reclaiming artifacts an older install already wrote. + +Kimi CLI also discovers user skills from the brand-specific `~/.kimi-code` directory. If you are genuinely on **Kimi CLI** and your setup is centered on `~/.kimi-code`, redirect its skills root explicitly: ```bash npx @opengsd/gsd-core@latest --kimi --global --config-dir ~/.kimi-code diff --git a/docs/migration/kimi-to-kimi-code.md b/docs/migration/kimi-to-kimi-code.md index 1420058cb..319fcc52b 100644 --- a/docs/migration/kimi-to-kimi-code.md +++ b/docs/migration/kimi-to-kimi-code.md @@ -42,11 +42,34 @@ If your prior `--kimi` install wrote agent YAMLs (the `kimi-agents` artifact lay rm -rf ~/.config/agents/agents/gsd-*.yaml ~/.agents/agents/gsd-*.yaml 2>/dev/null || true ``` -### 3. Verify skills are discovered +### 3. Reclaim GSD hooks a pre-1.10.0 install left in `~/.kimi` + +Before 1.10.0 (#2755), a `--kimi-code` install wrote its GSD `[[hooks]]` block, hook bundle and CommonJS marker into Kimi CLI's `~/.kimi/` instead of Kimi Code's own root. Upgrading fixes the destination but cannot clean up what the old bug already wrote, so those artifacts stay in `~/.kimi/` indefinitely — nothing reads them, and no uninstall path reaches them. + +Reclaim them by adding `--reclaim-kimi-legacy` to the re-install: + +```bash +npx @opengsd/gsd-core --kimi-code --global --reclaim-kimi-legacy +``` + +This removes GSD's managed `[[hooks]]` block from `~/.kimi/config.toml`, plus GSD's own hook scripts, `hooks/lib/` helpers and CommonJS marker under `~/.kimi/`. Only exact GSD-owned filenames are touched: your own `config.toml` sections, your own scripts, and any `package.json` you wrote yourself are left alone, and directories are removed only when that cleanup leaves them empty. + +> **Do not pass this flag if you also use Kimi CLI.** GSD wraps its entries in the same `# GSD Hooks BEGIN`/`END` markers whichever product it installed for, and the command paths inside them are derived from the hooks root — so a block the old bug wrote for Kimi Code is **byte-identical** to the one a legitimate `--kimi` install writes. Nothing on disk can tell them apart, which is exactly why this cleanup is opt-in rather than automatic: on a machine with both products, the flag would remove Kimi CLI's working hooks. If you use both, leave `~/.kimi` alone — the leftovers are inert for Kimi Code and harmless for Kimi CLI. To remove them later, uninstall Kimi CLI's install properly instead: `npx @opengsd/gsd-core --kimi --global --uninstall`. + +The flag never acts silently. It is skipped, with a notice saying so, in each case where reclaiming would be wrong or impossible: + +| Situation | What happens | +|---|---| +| The install is not `--kimi-code`, or is `--local` | Warns that the flag was ignored — nothing in `~/.kimi` is touched. | +| The same invocation also installs `--kimi` (including via `--all`) | Skipped: that run is creating a live Kimi CLI install in `~/.kimi`, so the flag's premise does not hold. | +| `KIMI_SHARE_DIR` and `KIMI_CODE_HOME` name the same directory | Skipped: there is no separate legacy root, and reclaiming would delete the hooks this install just wrote. Aliases count — a symlink or a case variant on a case-insensitive filesystem is recognized as the same directory. | +| No GSD artifacts are found in `~/.kimi` | Reports that there was nothing to reclaim. | + +### 4. Verify skills are discovered After re-install, launch Kimi Code and confirm the GSD skills appear in the `/skill:` menu (or whatever surface Kimi Code uses for auto-discovered Agent Skills). Each `gsd-*` skill should be present at `~/.kimi-code/skills/gsd-*/SKILL.md`. -### 4. Verify agent-skills query +### 5. Verify agent-skills query ```bash gsd-tools query agent-skills gsd-planner @@ -70,4 +93,5 @@ What that does and does not buy you (#2547): ## Questions - **Can I keep both `--kimi` and `--kimi-code` installs?** Yes — they install to separate config dirs (`~/.kimi/` vs `~/.kimi-code/`). Run both if you genuinely use both products. +- **I only ever used Kimi Code — why is there anything in `~/.kimi` at all?** A GSD install older than 1.10.0 put it there (#2755). See step 3 above to reclaim it. - **Do I need to uninstall the old `--kimi` install first?** No — `--kimi-code --global` writes to `~/.kimi-code/`, which is separate. But if you no longer use Python kimi-cli, uninstalling the old install keeps things clean: `npx @opengsd/gsd-core --kimi --global --uninstall`. diff --git a/tests/golden-parity-single-source.test.cjs b/tests/golden-parity-single-source.test.cjs index 2ee5491fd..7aab56eb7 100644 --- a/tests/golden-parity-single-source.test.cjs +++ b/tests/golden-parity-single-source.test.cjs @@ -282,3 +282,33 @@ describe('#1575 — surface path: no prune data-loss over pre-existing legacy ag }); }); } + +test('runMinimalInstall resolves local config dirs from RUNTIME_META alone (#3031)', () => { + // install-shared.cjs used to carry a SECOND, hand-maintained local-dir map + // beside RUNTIME_META. It drifted: four runtimes present in RUNTIME_META + // (hermes, kimi, kimi-code, zcode) were missing from it, so `scope: 'local'` + // for any of them resolved `path.join(root, undefined)` and threw a bare + // TypeError naming neither the runtime nor the map at fault. #3023 had + // already hit this for `pi` and fixed it by adding one more entry, which + // left the divergence itself intact for the next runtime to rediscover. + // + // Same anti-divergence pattern as the buildParityManifest guard above: the + // duplicate is gone, and this asserts it does not come back. + const helperSrc = fs.readFileSync( + path.join(ROOT, 'tests', 'helpers', 'install-shared.cjs'), + 'utf8', + ); + assert.doesNotMatch( + helperSrc, + /const\s+LOCAL_DIR_NAME\s*=/, + 'install-shared.cjs must not re-declare a second local-dir map beside RUNTIME_META', + ); + + // Every runtime the harness knows about must be usable at local scope. + const { RUNTIME_META } = require('./helpers/install-shared.cjs'); + const missing = Object.entries(RUNTIME_META) + .filter(([, meta]) => !meta.localDir) + .map(([runtime]) => runtime); + assert.deepEqual(missing, [], + 'every RUNTIME_META entry needs a localDir or local-scope installs throw on path.join(root, undefined)'); +}); diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index 125ae8d60..b93215b9f 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -559,17 +559,6 @@ function runMinimalInstall({ runtime, scope, extraArgs = [], installScript = INS const ownsRoot = providedRoot === null; const root = providedRoot ?? fs.mkdtempSync(path.join(os.tmpdir(), `gsd-${runtime}-${scope}-`)); try { - const LOCAL_DIR_NAME = { - claude: '.claude', opencode: '.opencode', kilo: '.kilo', - codex: '.codex', copilot: '.github', antigravity: '.agents', cursor: '.cursor', - windsurf: '.windsurf', augment: '.augment', trae: '.trae', qwen: '.qwen', - codebuddy: '.codebuddy', cline: '.', - // #3023: pi was in RUNTIME_META but absent here, so `scope: 'local'` for pi - // resolved `path.join(root, undefined)` and threw — no local-scope pi install - // could ever be exercised. pi's local config dir is `.pi` - // (capabilities/pi/capability.json runtime.localConfigDir). - pi: '.pi', - }; let configDir; let cwd = process.cwd(); const args = [installScript, `--${runtime}`]; @@ -602,7 +591,23 @@ function runMinimalInstall({ runtime, scope, extraArgs = [], installScript = INS } else { args.push('--local'); cwd = root; - configDir = runtime === 'cline' ? root : path.join(root, LOCAL_DIR_NAME[runtime]); + // #3031: local scope reads RUNTIME_META.localDir — the SAME table the + // global branch above reads — instead of a second hand-maintained map. + // That duplicate map was missing four runtimes (hermes, kimi, kimi-code, + // zcode), so `scope: 'local'` for any of them resolved + // `path.join(root, undefined)` and threw a bare TypeError naming neither + // the runtime nor the map at fault. #3023 fixed exactly this for `pi` by + // adding one more entry, which left the divergence itself in place; the + // table is now single-source so a new runtime cannot reintroduce it. + // `cline` keeps its ternary: its local artifacts land at the project root + // itself, which is a genuine exception rather than a directory name. + const localMeta = RUNTIME_META[runtime]; + if (runtime !== 'cline' && (!localMeta || !localMeta.localDir)) { + throw new Error( + `runMinimalInstall: no RUNTIME_META.localDir for runtime "${runtime}" — refusing to guess a local config dir (#3031)`, + ); + } + configDir = runtime === 'cline' ? root : path.join(root, localMeta.localDir); } args.push(...extraArgs); const result = runNode(args, { diff --git a/tests/kimi-upgrades.test.cjs b/tests/kimi-upgrades.test.cjs index 140d606ac..5da52fcc2 100644 --- a/tests/kimi-upgrades.test.cjs +++ b/tests/kimi-upgrades.test.cjs @@ -53,6 +53,8 @@ const { KIMI_HOOKS_TOML_MARKER_BEGIN, KIMI_HOOKS_TOML_MARKER_END, } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs'); +const { COMMONJS_MARKER_CONTENT } = require('../gsd-core/bin/lib/commonjs-marker.cjs'); +const fc = require('fast-check'); const KIMI_CAP = JSON.parse( fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'kimi', 'capability.json'), 'utf8'), @@ -348,14 +350,20 @@ function hasGsdHooksBlock(tomlPath) { return stripKimiHooksTomlBlock(content) !== content; } -/** Spawn the real installer for one Kimi variant against a shared sandbox HOME. */ -function runKimiInstall(root, runtime, { extraEnv = {}, uninstall = false } = {}) { +/** + * Spawn the real installer for one Kimi variant against a shared sandbox HOME. + * + * `extraArgs` (#3031) appends installer flags beyond the uninstall switch — the + * opt-in `--reclaim-kimi-legacy` path needs them, and threading them here keeps + * every Kimi test on one spawn helper. + */ +function runKimiInstall(root, runtime, { extraEnv = {}, uninstall = false, extraArgs = [] } = {}) { return runMinimalInstall({ runtime, scope: 'global', root, extraEnv, - extraArgs: uninstall ? ['--uninstall'] : [], + extraArgs: [...(uninstall ? ['--uninstall'] : []), ...extraArgs], }); } @@ -500,3 +508,273 @@ describe('kimi vs kimi-code hooks-TOML root (#2755)', () => { 'neither default hooks root may receive the GSD block when both overrides are set'); }); }); + + +// --------------------------------------------------------------------------- +// #3031: opt-in reclaim of the ~/.kimi artifacts a pre-#2755 install orphaned +// --------------------------------------------------------------------------- + +/** + * The GSD hook script name the bundle really installs, so seeded wreckage + * matches what the pre-#2755 installer actually left behind. + */ +const SEEDED_GSD_HOOK = 'gsd-check-update.js'; +const RECLAIM_FLAG = '--reclaim-kimi-legacy'; + +/** + * Seed a sandbox HOME with exactly what a pre-#2755 `--kimi-code` install left + * in `~/.kimi`: GSD's managed block in the native config.toml, a hook script, + * and the CommonJS marker inside hooks/. + */ +function seedLegacyKimiRoot(home, { userTomlPre = '', userTomlPost = '' } = {}) { + const root = path.join(home, '.kimi'); + const hooksDir = path.join(root, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + + const block = [ + KIMI_HOOKS_TOML_MARKER_BEGIN, + '', + '[[hooks]]', + 'event = "SessionStart"', + `command = "node \\"${toPosixPath(root)}/hooks/${SEEDED_GSD_HOOK}\\""`, + '', + KIMI_HOOKS_TOML_MARKER_END, + ].join('\n'); + + const parts = [userTomlPre, block, userTomlPost].filter((part) => part !== ''); + fs.writeFileSync(path.join(root, 'config.toml'), `${parts.join('\n\n')}\n`); + fs.writeFileSync(path.join(hooksDir, SEEDED_GSD_HOOK), '// GSD hook\n'); + fs.writeFileSync(path.join(hooksDir, 'package.json'), COMMONJS_MARKER_CONTENT); + + return { root, hooksDir, tomlPath: path.join(root, 'config.toml') }; +} + +describe('#3031 — opt-in reclaim of orphaned ~/.kimi GSD artifacts', () => { + test('reclaims the legacy ~/.kimi GSD artifacts when --reclaim-kimi-legacy is passed', (t) => { + const root = sandboxHome(t, 'gsd-3031-'); + const legacy = seedLegacyKimiRoot(root); + + runKimiInstall(root, 'kimi-code', { extraArgs: [RECLAIM_FLAG] }); + + assert.ok(!hasGsdHooksBlock(legacy.tomlPath), + 'the stale GSD [[hooks]] block must be gone from ~/.kimi/config.toml'); + assert.ok(!fs.existsSync(path.join(legacy.hooksDir, SEEDED_GSD_HOOK)), + 'the orphaned GSD hook script must be removed from ~/.kimi/hooks/'); + assert.ok(!fs.existsSync(path.join(legacy.hooksDir, 'package.json')), + 'the orphaned CommonJS marker must be removed from ~/.kimi/hooks/'); + assert.ok(hasGsdHooksBlock(path.join(root, '.kimi-code', 'config.toml')), + 'the kimi-code install itself must still have written its own GSD block'); + }); + + test('leaves ~/.kimi untouched without the flag (cleanup is opt-in)', (t) => { + const root = sandboxHome(t, 'gsd-3031-'); + const legacy = seedLegacyKimiRoot(root); + const before = fs.readFileSync(legacy.tomlPath, 'utf8'); + + runKimiInstall(root, 'kimi-code'); + + assert.equal(fs.readFileSync(legacy.tomlPath, 'utf8'), before, + '~/.kimi/config.toml must be byte-identical when the flag is absent'); + assert.ok(fs.existsSync(path.join(legacy.hooksDir, SEEDED_GSD_HOOK)), + 'the hook bundle must survive when the flag is absent'); + }); + + test('preserves user-authored config.toml sections and non-GSD hook files', (t) => { + const root = sandboxHome(t, 'gsd-3031-'); + const legacy = seedLegacyKimiRoot(root, { + userTomlPre: '[providers.moonshot]\napi_key = "USER-OWNED"', + userTomlPost: '[ui]\ntheme = "dark"', + }); + const userHook = path.join(legacy.hooksDir, 'my-own-hook.js'); + fs.writeFileSync(userHook, '// authored by the user\n'); + + runKimiInstall(root, 'kimi-code', { extraArgs: [RECLAIM_FLAG] }); + + const after = fs.readFileSync(legacy.tomlPath, 'utf8'); + assert.ok(!hasGsdHooksBlock(legacy.tomlPath), 'the GSD block must be gone'); + assert.match(after, /api_key = "USER-OWNED"/, 'user provider section must survive'); + assert.match(after, /theme = "dark"/, 'user ui section must survive'); + assert.ok(fs.existsSync(userHook), 'a user-authored hook script must never be removed'); + }); + + test('never reclaims when the install runtime is kimi itself', (t) => { + const root = sandboxHome(t, 'gsd-3031-'); + const legacy = seedLegacyKimiRoot(root); + + runKimiInstall(root, 'kimi', { extraArgs: [RECLAIM_FLAG] }); + + assert.ok(hasGsdHooksBlock(legacy.tomlPath), + 'a --kimi install must never reclaim ~/.kimi — that is its own hooks root'); + }); + + test('skips reclaim when the legacy root resolves to the install root', (t) => { + // Both overrides pointed at ONE directory collapse "the legacy root" onto + // "the root this install just wrote". An unguarded reclaim would delete its + // own output. + const root = sandboxHome(t, 'gsd-3031-'); + const shared = sandboxHome(t, 'gsd-3031-shared-'); + + runKimiInstall(root, 'kimi-code', { + extraArgs: [RECLAIM_FLAG], + extraEnv: { KIMI_SHARE_DIR: shared, KIMI_CODE_HOME: shared }, + }); + + assert.ok(hasGsdHooksBlock(path.join(shared, 'config.toml')), + 'reclaim must not delete the hooks block this very install just wrote'); + }); + + test('never reclaims when the same invocation also installs kimi', (t) => { + // The flag asserts "I only use Kimi Code". `--all` — or an explicit + // `--kimi --kimi-code` — falsifies that, and selectRuntimesFromArgs puts + // `kimi` BEFORE `kimi-code` in both, so an unguarded reclaim installs Kimi + // CLI's hooks and deletes them moments later in the same run, exiting 0. + const root = sandboxHome(t, 'gsd-3031-'); + + // Adding `--kimi` to a kimi-code install makes selectRuntimesFromArgs + // return BOTH runtimes, exactly as `--all` does. + runKimiInstall(root, 'kimi-code', { extraArgs: ['--kimi', RECLAIM_FLAG] }); + + assert.ok(hasGsdHooksBlock(path.join(root, '.kimi', 'config.toml')), + 'Kimi CLI hooks installed by this same run must survive the reclaim'); + assert.ok(hasGsdHooksBlock(path.join(root, '.kimi-code', 'config.toml')), + 'the kimi-code install itself must still succeed'); + }); + + test('skips reclaim when the two roots differ only by a symlink alias', (t) => { + // The roots come from user-controlled env vars, so "same directory" and + // "same string" are not the same question. A resolve-only comparison + // returns false here and the reclaim would delete the hooks this very + // install just wrote. + if (process.platform === 'win32') { + t.skip('symlink creation requires elevated privileges on Windows'); + return; + } + const root = sandboxHome(t, 'gsd-3031-'); + const real = path.join(root, 'realkimi'); + const link = path.join(root, 'linkkimi'); + fs.mkdirSync(real, { recursive: true }); + fs.symlinkSync(real, link); + + runKimiInstall(root, 'kimi-code', { + extraArgs: [RECLAIM_FLAG], + extraEnv: { KIMI_CODE_HOME: real, KIMI_SHARE_DIR: link }, + }); + + assert.ok(hasGsdHooksBlock(path.join(real, 'config.toml')), + 'a symlinked alias of the install root must not be reclaimed'); + }); + + test('skips reclaim when the two roots differ only by case on a case-insensitive filesystem', (t) => { + const root = sandboxHome(t, 'gsd-3031-'); + const real = path.join(root, 'casekimi'); + const upper = path.join(root, 'CASEKIMI'); + fs.mkdirSync(real, { recursive: true }); + + // Probe the ACTUAL filesystem rather than inferring from process.platform: + // macOS can be formatted case-sensitively and Linux can mount otherwise. + if (!fs.existsSync(upper)) { + t.skip('filesystem is case-sensitive — these are genuinely two directories'); + return; + } + + runKimiInstall(root, 'kimi-code', { + extraArgs: [RECLAIM_FLAG], + extraEnv: { KIMI_CODE_HOME: real, KIMI_SHARE_DIR: upper }, + }); + + assert.ok(hasGsdHooksBlock(path.join(real, 'config.toml')), + 'a case-variant alias of the install root must not be reclaimed'); + }); + + test('is a no-op when no legacy ~/.kimi root exists', (t) => { + const root = sandboxHome(t, 'gsd-3031-'); + + runKimiInstall(root, 'kimi-code', { extraArgs: [RECLAIM_FLAG] }); + + assert.ok(!fs.existsSync(path.join(root, '.kimi')), + 'reclaim must not create a legacy root that never existed'); + assert.ok(hasGsdHooksBlock(path.join(root, '.kimi-code', 'config.toml')), + 'the kimi-code install itself must still succeed'); + }); + + test('is idempotent across repeated reclaims', (t) => { + const root = sandboxHome(t, 'gsd-3031-'); + const legacy = seedLegacyKimiRoot(root); + const read = () => (fs.existsSync(legacy.tomlPath) + ? fs.readFileSync(legacy.tomlPath, 'utf8') + : null); + + // runKimiInstall asserts exit 0, so a crash on the second pass fails here. + runKimiInstall(root, 'kimi-code', { extraArgs: [RECLAIM_FLAG] }); + const afterFirst = read(); + runKimiInstall(root, 'kimi-code', { extraArgs: [RECLAIM_FLAG] }); + + assert.equal(read(), afterFirst, 'a second reclaim must change nothing further'); + }); + + test('warns instead of silently no-opping when the flag cannot apply', (t) => { + // The flag only ever acts inside the kimi-code GLOBAL branch. Consuming it + // in silence is indistinguishable from "it ran and found nothing", which + // for a cleanup the user explicitly asked for is the wrong answer. + const wrongRuntime = runMinimalInstall({ + runtime: 'claude', + scope: 'global', + root: sandboxHome(t, 'gsd-3031-'), + extraArgs: [RECLAIM_FLAG], + }); + assert.match(`${wrongRuntime.stdout}${wrongRuntime.stderr}`, /--reclaim-kimi-legacy ignored/, + 'a non-kimi-code install must say the flag did nothing'); + + // kimi-code declares hostBehaviors.localInstallDeferred, so install() + // returns before the hooks branch — the scope warning has to be raised + // before that early return, not inside it. + const localScope = runMinimalInstall({ + runtime: 'kimi-code', + scope: 'local', + root: sandboxHome(t, 'gsd-3031-'), + extraArgs: [RECLAIM_FLAG], + }); + assert.match(`${localScope.stdout}${localScope.stderr}`, /--reclaim-kimi-legacy ignored/, + 'a local install must say the flag did nothing'); + }); + + test('does not warn on the happy path', (t) => { + const root = sandboxHome(t, 'gsd-3031-'); + seedLegacyKimiRoot(root); + + const result = runKimiInstall(root, 'kimi-code', { extraArgs: [RECLAIM_FLAG] }); + + assert.doesNotMatch(`${result.stdout}${result.stderr}`, /--reclaim-kimi-legacy ignored/, + 'the warning must not fire when the reclaim actually runs'); + }); + + test('property: stripping the GSD block never destroys user content', () => { + const userText = fc.stringMatching(/^[A-Za-z0-9_= ."[\]]{1,40}$/); + + fc.assert( + fc.property(userText, userText, (pre, post) => { + const block = [ + KIMI_HOOKS_TOML_MARKER_BEGIN, + '', + '[[hooks]]', + 'event = "SessionStart"', + '', + KIMI_HOOKS_TOML_MARKER_END, + ].join('\n'); + const stripped = stripKimiHooksTomlBlock(`${pre}\n\n${block}\n\n${post}\n`); + + assert.ok(stripped === null || !stripped.includes(KIMI_HOOKS_TOML_MARKER_BEGIN), + 'the managed block itself must always be removed'); + + const survives = (text) => { + const trimmed = text.trim(); + if (trimmed === '') return true; + return stripped !== null && stripped.includes(trimmed); + }; + assert.ok(survives(pre), `user prefix lost: ${JSON.stringify(pre)}`); + assert.ok(survives(post), `user suffix lost: ${JSON.stringify(post)}`); + }), + { numRuns: 100 }, + ); + }); +});