diff --git a/src/daemon/agents/kimi.ts b/src/daemon/agents/kimi.ts index eaa6d33..7be7f38 100644 --- a/src/daemon/agents/kimi.ts +++ b/src/daemon/agents/kimi.ts @@ -34,57 +34,80 @@ import { assertSandboxSupported, sandboxFailClosed } from './sandbox-guard.js'; /** * Two ways to talk to Kimi K2.6: * - * - Standalone `kimi` CLI (paid Moonshot subscription) — Claude-Code- - * compatible, supports streaming text deltas. Requires the user to - * have wired `default_model` or a `[models]` block in - * `~/.kimi/config.toml`. Out-of-box config is empty → exits 1 with - * "LLM not set". + * - Standalone `kimi` CLI — Claude-Code-compatible, supports streaming + * text deltas via `--print --output-format stream-json`. Two builds + * share this binary: native Kimi Code (code.kimi.com → `~/.kimi-code`, + * account-authed via `kimi login`) and the legacy Python kimi-cli + * (needs a `default_model`/`[models]` block in `~/.kimi/config.toml`; + * an empty config exits 1 with "LLM not set"). * * - `opencode run --format json --model opencode-go/kimi-k2.6` (paid * OpenCode Go subscription) — same model under the hood, routed * through OpenCode. JSON-Lines output; one text event per LLM * message. The fleet and openbridge journals use this path. * - * Most users have ONE of the two paid plans, not both. Auto-detect at - * shim init: if standalone kimi has a model configured, use it; else - * fall back to opencode + opencode-go. Override via env - * `CHORUS_KIMI_TRANSPORT=kimi-cli|opencode|auto` (default auto). + * `chooseKimiTransport` (below) holds the full precedence. In short: env + * override wins; a native `~/.kimi-code` install or a configured Python + * kimi-cli drives `kimi` directly; otherwise fall back to opencode-go. + * Override via env `CHORUS_KIMI_TRANSPORT=kimi-cli|opencode` (default auto). */ type KimiTransport = 'kimi-cli' | 'opencode'; let cachedTransport: KimiTransport | null = null; -function detectKimiTransport(): KimiTransport { - if (cachedTransport) return cachedTransport; +/** + * Pure transport decision — no module-level cache, takes the home dir and + * the raw env override as arguments so it's testable without touching the + * real `~`. `detectKimiTransport()` wraps it with the cache + real homedir. + * + * Precedence: + * 1. `CHORUS_KIMI_TRANSPORT` env override always wins. + * 2. Native Kimi Code (code.kimi.com → `~/.kimi-code`) → drive `kimi` + * directly. It's account-authed via `kimi login` and has no + * `~/.kimi/config.toml`, so the legacy config probe below would have + * wrongly shunted it to opencode-go — ignoring the user's install or + * failing outright when they lack an OpenCode Go subscription (#98). + * 3. Python kimi-cli with a wired model in `~/.kimi/config.toml` (empty + * config exits 1 "LLM not set", so a populated one is the gate). + * 4. Otherwise fall back to opencode + opencode-go. + */ +export function chooseKimiTransport( + homeDir: string, + override?: string, +): KimiTransport { + if (override === 'kimi-cli' || override === 'opencode') return override; - const override = process.env.CHORUS_KIMI_TRANSPORT; - if (override === 'kimi-cli' || override === 'opencode') { - cachedTransport = override; - return override; - } + // Native Kimi Code install — the active `kimi` on PATH (its installer + // renames any prior Python kimi-cli to `kimi-legacy`). Drive it directly. + if (fs.existsSync(path.join(homeDir, '.kimi-code'))) return 'kimi-cli'; - // Probe the standalone kimi config — non-empty `default_model` OR any - // `[models.]` table means the user has wired up a real model. - const configPath = path.join(os.homedir(), '.kimi', 'config.toml'); + // Standalone Python kimi-cli — usable only when a model is wired: + // non-empty `default_model` OR any `[models.]` table. + const configPath = path.join(homeDir, '.kimi', 'config.toml'); if (fs.existsSync(configPath)) { try { const body = fs.readFileSync(configPath, 'utf-8'); const defaultModel = body.match(/^\s*default_model\s*=\s*["']([^"']+)["']/m); const hasDefault = defaultModel != null && defaultModel[1].length > 0; const hasModelsTable = /^\[models\.[A-Za-z0-9_.-]+\]/m.test(body); - if (hasDefault || hasModelsTable) { - cachedTransport = 'kimi-cli'; - return 'kimi-cli'; - } + if (hasDefault || hasModelsTable) return 'kimi-cli'; } catch { /* fall through */ } } - cachedTransport = 'opencode'; return 'opencode'; } +function detectKimiTransport(): KimiTransport { + if (cachedTransport) return cachedTransport; + cachedTransport = chooseKimiTransport( + os.homedir(), + process.env.CHORUS_KIMI_TRANSPORT, + ); + return cachedTransport; +} + /** * Drop a per-participant `_meta.json` sidecar so the cockpit can show * which binary + model actually ran. Without this the run page would diff --git a/src/lib/cli-detect.ts b/src/lib/cli-detect.ts index 8c8fce4..bd838f1 100644 --- a/src/lib/cli-detect.ts +++ b/src/lib/cli-detect.ts @@ -147,7 +147,14 @@ function fallbackPaths(cli: DetectableCli): string[] { dirs.push(path.join(HOME, '.opencode', 'bin')); } if (cli === 'kimi-cli') { - dirs.push(path.join(HOME, '.kimi', 'bin')); + // Two kimi builds, two installers: the Python kimi-cli + // (MoonshotAI/kimi-cli) drops into ~/.kimi/bin, while the native Kimi + // Code build (code.kimi.com) installs to ~/.kimi-code/bin. Probe both + // so a user who didn't add either to PATH is still detected (#98). + dirs.push( + path.join(HOME, '.kimi', 'bin'), + path.join(HOME, '.kimi-code', 'bin'), + ); } if (cli === 'grok-cli') { // xAI's installer drops binaries here (curl|bash from x.ai/cli). @@ -226,7 +233,13 @@ const CLI_SIGNATURES: Record = { 'gemini-cli': STARTS_WITH_VERSION, // Bare version output — "1.14.30" — same as gemini. 'opencode-cli': STARTS_WITH_VERSION, - 'kimi-cli': /\bkimi\b/i, + // Two kimi builds share the `kimi` binary name: the Python kimi-cli + // (MoonshotAI/kimi-cli) prints "kimi, version 1.46.0" (name token present), + // while the native Kimi Code build (code.kimi.com, ~/.kimi-code) prints a + // bare semver like "0.6.0" with NO name token. Accept either — the + // basename allowlist already gates on the binary being named `kimi`, so a + // bare-version match here is as safe as it is for gemini/opencode. (#98) + 'kimi-cli': /\bkimi\b|^\s*v?\d+\.\d+/i, // xAI's grok CLI — actual --version output unverified at time of // writing (binary execution sandboxed off in this env). Accepting // either a "grok" name token OR a bare version string; basename diff --git a/tests/cli-detect-utils.test.ts b/tests/cli-detect-utils.test.ts index add354f..dbbceee 100644 --- a/tests/cli-detect-utils.test.ts +++ b/tests/cli-detect-utils.test.ts @@ -28,17 +28,19 @@ beforeAll(() => { afterAll(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); -function stageBinary(name: string): string { +function stageBinary(name: string, versionOutput = 'claude 0.0.0-test'): string { // Unique-suffix the directory so the same basename can be staged // multiple times across tests without colliding (e.g., claude + claude.cmd). // The stub must echo a string matching CLI_SIGNATURES[cli] — for // claude-code that's /\bclaude\b/i. verifyRunnable spawns the binary // with --version and pattern-matches the output, so an empty stub // would fail the signature gate even though the file exists. + // versionOutput is overridable so a CLI whose --version shape differs + // (e.g. native Kimi Code prints a bare semver) can be exercised too. const dir = path.join(tmpDir, randomUUID()); fs.mkdirSync(dir, { recursive: true }); const full = path.join(dir, name); - fs.writeFileSync(full, '#!/bin/sh\necho "claude 0.0.0-test"\n', { mode: 0o755 }); + fs.writeFileSync(full, `#!/bin/sh\necho "${versionOutput}"\n`, { mode: 0o755 }); return full; } @@ -156,4 +158,33 @@ describe('validateCliPath — basename gate', () => { expect(result.found).toBe(false); expect(result.reason).toContain('no file at'); }); -}); \ No newline at end of file +}); + +describe('kimi-cli signature — two builds share the `kimi` binary (issue #98)', () => { + it('accepts the Python kimi-cli --version output ("kimi, version 1.46.0")', () => { + // MoonshotAI/kimi-cli (pip/uv/npm) prints its name in the version line. + const staged = stageBinary('kimi', 'kimi, version 1.46.0'); + const result = validateCliPath('kimi-cli', staged); + expect(result.found).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it('accepts the native Kimi Code --version output (bare semver "0.6.0")', () => { + // Native Kimi Code (code.kimi.com → ~/.kimi-code/bin/kimi) prints a bare + // semver with NO name token. The basename gate already confirmed the + // binary is named `kimi`, so the signature must not reject it. This is + // the regression issue #98 reports. + const staged = stageBinary('kimi', '0.6.0'); + const result = validateCliPath('kimi-cli', staged); + expect(result.found).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it('still rejects a `kimi`-named binary whose --version is junk', () => { + // The basename gate alone isn't enough — a binary named kimi that prints + // something that's neither the name token nor a version is still bogus. + const staged = stageBinary('kimi', 'not a version at all'); + const result = validateCliPath('kimi-cli', staged); + expect(result.found).toBe(false); + }); +}); diff --git a/tests/kimi-transport.test.ts b/tests/kimi-transport.test.ts new file mode 100644 index 0000000..1f37154 --- /dev/null +++ b/tests/kimi-transport.test.ts @@ -0,0 +1,86 @@ +/** + * Transport selection for the kimi shim (issue #98). + * + * There are two `kimi` builds that share the binary name: + * - Python kimi-cli (MoonshotAI/kimi-cli) → wired via ~/.kimi/config.toml + * - native Kimi Code (code.kimi.com) → installs to ~/.kimi-code, + * account-authed via `kimi login` (no config.toml) + * + * `chooseKimiTransport` decides whether to drive the `kimi` binary directly + * (kimi-cli transport) or route through opencode-go. Before the fix, a + * Kimi-Code-only user (no ~/.kimi/config.toml) was silently shunted to the + * opencode path — which ignores their kimi install or fails outright when + * they lack an OpenCode Go subscription. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { chooseKimiTransport } from '../src/daemon/agents/kimi.js'; + +let tmpRoot: string; +beforeAll(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'chorus-kimi-transport-')); +}); +afterAll(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}); + +/** Fresh empty fake-home per test so cases don't bleed into each other. */ +let home: string; +beforeEach(() => { + home = path.join(tmpRoot, randomUUID()); + fs.mkdirSync(home, { recursive: true }); +}); + +function writeKimiConfig(body: string): void { + const dir = path.join(home, '.kimi'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'config.toml'), body, 'utf-8'); +} + +describe('chooseKimiTransport', () => { + it('drives kimi directly when native Kimi Code (~/.kimi-code) is installed', () => { + fs.mkdirSync(path.join(home, '.kimi-code', 'bin'), { recursive: true }); + expect(chooseKimiTransport(home)).toBe('kimi-cli'); + }); + + it('drives kimi directly when ~/.kimi/config.toml wires a default_model', () => { + writeKimiConfig('default_model = "kimi-k2.6"\n'); + expect(chooseKimiTransport(home)).toBe('kimi-cli'); + }); + + it('drives kimi directly when ~/.kimi/config.toml has a [models.x] table', () => { + writeKimiConfig('[models.foo]\napi_key = "x"\n'); + expect(chooseKimiTransport(home)).toBe('kimi-cli'); + }); + + it('falls back to opencode when neither kimi build is configured', () => { + expect(chooseKimiTransport(home)).toBe('opencode'); + }); + + it('falls back to opencode for an unconfigured ~/.kimi/config.toml (empty model)', () => { + writeKimiConfig('# no model set\n'); + expect(chooseKimiTransport(home)).toBe('opencode'); + }); + + it('prefers native Kimi Code over an unconfigured ~/.kimi/config.toml', () => { + // The exact bug: an empty ~/.kimi/config.toml would have yielded + // opencode, but a present ~/.kimi-code install means the active `kimi` + // binary is the native build and must be driven directly. + fs.mkdirSync(path.join(home, '.kimi-code'), { recursive: true }); + writeKimiConfig('# no model set\n'); + expect(chooseKimiTransport(home)).toBe('kimi-cli'); + }); + + it('honours CHORUS_KIMI_TRANSPORT=opencode override even when ~/.kimi-code exists', () => { + fs.mkdirSync(path.join(home, '.kimi-code'), { recursive: true }); + expect(chooseKimiTransport(home, 'opencode')).toBe('opencode'); + }); + + it('honours CHORUS_KIMI_TRANSPORT=kimi-cli override even when nothing is configured', () => { + expect(chooseKimiTransport(home, 'kimi-cli')).toBe('kimi-cli'); + }); +});