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 }, + ); + }); +});