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
6 changes: 6 additions & 0 deletions src/lib/runtime-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ const KNOWN_INSTALL_DIRS = [
'~/.codex/bin',
'~/.gemini/bin',
'~/.kimi/bin',
// Native Kimi Code (code.kimi.com) installs here, grok (xAI) here. Both
// are probed by detection's fallbackPaths, so they must also reach the
// spawn PATH or a fallback-detected binary ENOENTs on bare-name spawn.
// Keep in sync with the per-CLI dirs in cli-detect.ts:fallbackPaths (#98).
'~/.kimi-code/bin',
'~/.grok/bin',
'~/.claude/local',
'~/.bun/bin',
'~/.deno/bin',
Expand Down
76 changes: 76 additions & 0 deletions tests/runtime-path-install-dirs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* The spawn PATH (KNOWN_INSTALL_DIRS in runtime-path.ts) must include every
* per-CLI install dir that detection probes (fallbackPaths in cli-detect.ts).
* When the two lists drift, a CLI gets DETECTED but the daemon can't SPAWN it
* by bare name → ENOENT.
*
* Concretely (#98 follow-up): native Kimi Code installs to ~/.kimi-code/bin
* and grok to ~/.grok/bin. Both are probed by detection's fallback scan, so a
* user who installed there without updating PATH gets a green "detected" — but
* `runHeadless` spawns bare `kimi`/`grok`, which would fail unless the dir is
* also on the merged spawn PATH built here.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';

import { _resetDbForTests, getDb } from '@/lib/db';
import { buildRuntimePath } from '@/lib/runtime-path';

let dbPath: string;
let fakeHome: string;
let realHome: string | undefined;

beforeEach(async () => {
dbPath = path.join(os.tmpdir(), `chorus-runtime-path-${randomUUID()}.db`);
process.env.CHORUS_DB_PATH = dbPath;
await _resetDbForTests();
await getDb();
// Point os.homedir() at a throwaway dir (honoured via $HOME on POSIX) so
// we control which per-tool install dirs "exist".
fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'chorus-fakehome-'));
realHome = process.env.HOME;
process.env.HOME = fakeHome;
});

afterEach(async () => {
if (realHome === undefined) delete process.env.HOME;
else process.env.HOME = realHome;
await _resetDbForTests();
for (const suffix of ['', '-shm', '-wal']) {
try { fs.unlinkSync(dbPath + suffix); } catch { /* best-effort */ }
}
delete process.env.CHORUS_DB_PATH;
fs.rmSync(fakeHome, { recursive: true, force: true });
});

describe('buildRuntimePath — per-CLI install dirs reach the spawn PATH (#98 follow-up)', () => {
it('includes ~/.kimi-code/bin (native Kimi Code) when it exists on disk', async () => {
const dir = path.join(fakeHome, '.kimi-code', 'bin');
fs.mkdirSync(dir, { recursive: true });
const merged = await buildRuntimePath();
expect(merged.split(path.delimiter)).toContain(dir);
});

it('includes ~/.grok/bin (xAI grok) when it exists on disk', async () => {
const dir = path.join(fakeHome, '.grok', 'bin');
fs.mkdirSync(dir, { recursive: true });
const merged = await buildRuntimePath();
expect(merged.split(path.delimiter)).toContain(dir);
});

it('still includes the long-standing ~/.kimi/bin (legacy Python kimi-cli)', async () => {
const dir = path.join(fakeHome, '.kimi', 'bin');
fs.mkdirSync(dir, { recursive: true });
const merged = await buildRuntimePath();
expect(merged.split(path.delimiter)).toContain(dir);
});

it('omits ~/.kimi-code/bin when it does not exist (no phantom PATH entries)', async () => {
const dir = path.join(fakeHome, '.kimi-code', 'bin');
const merged = await buildRuntimePath();
expect(merged.split(path.delimiter)).not.toContain(dir);
});
});
Loading