Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 47 additions & 24 deletions src/daemon/agents/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>]` 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.<name>]` 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
Expand Down
17 changes: 15 additions & 2 deletions src/lib/cli-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -226,7 +233,13 @@ const CLI_SIGNATURES: Record<DetectableCli, RegExp> = {
'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
Expand Down
37 changes: 34 additions & 3 deletions tests/cli-detect-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -156,4 +158,33 @@ describe('validateCliPath — basename gate', () => {
expect(result.found).toBe(false);
expect(result.reason).toContain('no file at');
});
});
});

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);
});
});
86 changes: 86 additions & 0 deletions tests/kimi-transport.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading