diff --git a/.changeset/2505-5-kimi-variant-disambiguation.md b/.changeset/2505-5-kimi-variant-disambiguation.md new file mode 100644 index 000000000..a42a5eaf4 --- /dev/null +++ b/.changeset/2505-5-kimi-variant-disambiguation.md @@ -0,0 +1,6 @@ +--- +type: Added +pr: 2535 +--- + +**The installer now distinguishes Kimi CLI (Python) from Kimi Code (Node) at install time** — running `--kimi` or `--kimi-code` prints a one-line description of each product, and if the selected variant doesn't match the detected `~/.kimi/config.toml` vs `~/.kimi-code/config.toml`, the installer warns with the correct `--kimi-code` / `--kimi` re-run command. Catches the "ran `--kimi --global` but actually on Kimi Code" mistake that produced inert YAMLs and empty agent-skills before the Phase 1 descriptor split. (#2513) diff --git a/bin/install.js b/bin/install.js index a83cfba7b..791942c50 100755 --- a/bin/install.js +++ b/bin/install.js @@ -658,6 +658,62 @@ function selectRuntimesFromArgs(runtimeArgs) { // Runtime selection - can be set by flags or interactive prompt let selectedRuntimes = selectRuntimesFromArgs(args); +// #2505 Phase 5: Kimi variant disambiguation (#2513). Kimi CLI (Python, ~/.kimi/) +// and Kimi Code (Node, ~/.kimi-code/) are two distinct Moonshot products that +// share the "kimi" brand. Probe for each product's config.toml and warn when +// the selected runtime doesn't match the detected install — catches the common +// "ran --kimi --global but actually on Kimi Code" mistake that produced inert +// YAMLs and empty agent-skills before the Phase 1 descriptor split. +function disambiguateKimiVariant(runtimes) { + const home = os.homedir(); + const hasKimiCli = fs.existsSync(path.join(home, '.kimi', 'config.toml')); + const hasKimiCode = fs.existsSync(path.join(home, '.kimi-code', 'config.toml')); + const notices = []; + if (runtimes.includes('kimi') && hasKimiCode && !hasKimiCli) { + notices.push({ + kind: 'wrong-variant', + selected: 'kimi', + detected: 'kimi-code', + message: `Detected ~/.kimi-code/config.toml (Kimi Code, Node CLI) but not ~/.kimi/config.toml (Kimi CLI, Python). You selected --kimi but appear to be on Kimi Code. Re-run with --kimi-code for a working install. (Kimi CLI = Python kimi-cli with named subagents; Kimi Code = Node CLI with coder/explore/plan built-ins only.)`, + }); + } + if (runtimes.includes('kimi-code') && hasKimiCli && !hasKimiCode) { + notices.push({ + kind: 'wrong-variant', + selected: 'kimi-code', + detected: 'kimi', + message: `Detected ~/.kimi/config.toml (Kimi CLI, Python) but not ~/.kimi-code/config.toml (Kimi Code, Node CLI). You selected --kimi-code but appear to be on Kimi CLI. Re-run with --kimi for a working install.`, + }); + } + // Distinct-entry descriptions when either Kimi variant is selected. + if (runtimes.includes('kimi')) { + notices.push({ + kind: 'description', + runtime: 'kimi', + message: 'Kimi CLI (Python kimi-cli): named subagents via YAML, config at ~/.kimi/, hooks via ~/.kimi/config.toml [[hooks]].', + }); + } + if (runtimes.includes('kimi-code')) { + notices.push({ + kind: 'description', + runtime: 'kimi-code', + message: 'Kimi Code (Node CLI): three built-in subagents (coder/explore/plan), Agent Skills at ~/.kimi-code/skills/, config at ~/.kimi-code/.', + }); + } + return notices; +} + +if (selectedRuntimes.includes('kimi') || selectedRuntimes.includes('kimi-code')) { + const kimiNotices = disambiguateKimiVariant(selectedRuntimes); + for (const notice of kimiNotices) { + if (notice.kind === 'wrong-variant') { + console.error(`${yellow}⚠ Kimi variant mismatch (${notice.selected} → ${notice.detected}).${reset} ${notice.message}`); + } else if (notice.kind === 'description') { + console.log(`${dim} ${notice.runtime}: ${notice.message}${reset}`); + } + } +} + // #1928: Google sunset Gemini CLI on 2026-06-18; Antigravity CLI is its // official successor. `--gemini` is no longer a valid runtime selector — // selectRuntimesFromArgs above no longer recognizes it, so it never lands in diff --git a/tests/kimi-variant-disambiguation.test.cjs b/tests/kimi-variant-disambiguation.test.cjs new file mode 100644 index 000000000..6b00290a4 --- /dev/null +++ b/tests/kimi-variant-disambiguation.test.cjs @@ -0,0 +1,94 @@ +// allow-test-rule: behavioral-subprocess-test — Phase 5 kimi-variant +// disambiguation is verified via install.js subprocess output capture, since +// the disambiguateKimiVariant function is inline in bin/install.js (not +// exported). The test sets a disposable HOME, creates the probe config files, +// and asserts on the printed notices. +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { cleanup } = require('./helpers.cjs'); + +const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); + +function makeDisposableHome() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-disambig-')); + return { + dir, + cleanup() { cleanup(dir); }, + }; +} + +function runInstall(args, home) { + // spawnSync captures stdout AND stderr separately regardless of exit code + // (execFileSync drops stderr on success, which hid the console.error warnings). + const r = spawnSync('node', [INSTALL_JS, ...args], { + env: { ...process.env, HOME: home, GSD_TEST_MODE: '1' }, + encoding: 'utf8', + timeout: 15000, + }); + return { stdout: r.stdout || '', stderr: r.stderr || '', exit: r.status }; +} + +describe('Kimi variant disambiguation (#2505 Phase 5 / #2513)', () => { + let home; + before(() => { home = makeDisposableHome(); }); + after(() => home.cleanup()); + + test('--kimi prints the Kimi CLI (Python) description', () => { + const r = runInstall(['--kimi', '--help'], home.dir); + const combined = r.stdout + r.stderr; + assert.match(combined, /Kimi CLI \(Python kimi-cli\).*named subagents.*~\/\.kimi\//); + }); + + test('--kimi-code prints the Kimi Code (Node CLI) description', () => { + const r = runInstall(['--kimi-code', '--help'], home.dir); + const combined = r.stdout + r.stderr; + assert.match(combined, /Kimi Code \(Node CLI\).*coder\/explore\/plan.*~\/\.kimi-code\//); + }); + + test('--kimi warns when only ~/.kimi-code/config.toml exists', () => { + const h = makeDisposableHome(); + try { + fs.mkdirSync(path.join(h.dir, '.kimi-code'), { recursive: true }); + fs.writeFileSync(path.join(h.dir, '.kimi-code', 'config.toml'), '# kimi-code'); + const r = runInstall(['--kimi', '--help'], h.dir); + const combined = r.stdout + r.stderr; + assert.match(combined, /variant mismatch/i); + assert.match(combined, /Detected ~\/\.kimi-code\/config\.toml/); + assert.match(combined, /Re-run with --kimi-code/); + } finally { + h.cleanup(); + } + }); + + test('--kimi-code warns when only ~/.kimi/config.toml exists', () => { + const h = makeDisposableHome(); + try { + fs.mkdirSync(path.join(h.dir, '.kimi'), { recursive: true }); + fs.writeFileSync(path.join(h.dir, '.kimi', 'config.toml'), '# kimi-cli'); + const r = runInstall(['--kimi-code', '--help'], h.dir); + const combined = r.stdout + r.stderr; + assert.match(combined, /variant mismatch/i); + assert.match(combined, /Detected ~\/\.kimi\/config\.toml/); + assert.match(combined, /Re-run with --kimi/); + } finally { + h.cleanup(); + } + }); + + test('no warning when neither config exists (fresh install)', () => { + const h = makeDisposableHome(); + try { + const r = runInstall(['--kimi', '--help'], h.dir); + const combined = r.stdout + r.stderr; + assert.doesNotMatch(combined, /variant mismatch/i); + } finally { + h.cleanup(); + } + }); +});