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
7 changes: 7 additions & 0 deletions .changeset/windows-git-bash-preflight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code-sdk": patch
"@moonshot-ai/kimi-code": patch
---

Fail early when Git Bash is missing on Windows before starting CLI sessions.
1 change: 1 addition & 0 deletions apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export async function runPrompt(
removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanupPromptRun);

try {
await harness.checkRuntimeEnvironment();
await harness.ensureConfigFile();
const config = await harness.getConfig();
const { session, resumed, restorePermission, telemetryModel, goalModel } =
Expand Down
32 changes: 17 additions & 15 deletions apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,6 @@ export async function runShell(
runOptions: { readonly migrateOnly?: boolean } = {},
): Promise<void> {
const startedAt = Date.now();
const configStartedAt = startedAt;
let tuiConfig: TuiConfig;
let configWarning: string | undefined;
try {
tuiConfig = await loadTuiConfig();
} catch (error) {
if (!(error instanceof TuiConfigParseError)) throw error;
tuiConfig = error.fallback;
configWarning = error.message;
}

// Resolve `theme = "auto"` against the live terminal once, before pi-tui
// grabs stdin. Explicit `dark` / `light` skip detection.
const resolvedTheme = tuiConfig.theme === 'auto' ? await detectTerminalTheme() : tuiConfig.theme;

const workDir = process.cwd();
const telemetryBootstrap = createCliTelemetryBootstrap();
const telemetryClient: TelemetryClient = {
Expand Down Expand Up @@ -78,6 +63,23 @@ export async function runShell(
platform: `${process.platform}/${process.arch}`,
workDir,
});
await harness.checkRuntimeEnvironment();

const configStartedAt = Date.now();
let tuiConfig: TuiConfig;
let configWarning: string | undefined;
try {
tuiConfig = await loadTuiConfig();
} catch (error) {
if (!(error instanceof TuiConfigParseError)) throw error;
tuiConfig = error.fallback;
configWarning = error.message;
}

// Resolve `theme = "auto"` against the live terminal once, before pi-tui
// grabs stdin. Explicit `dark` / `light` skip detection.
const resolvedTheme = tuiConfig.theme === 'auto' ? await detectTerminalTheme() : tuiConfig.theme;

await harness.ensureConfigFile();
const migrationPlan = await detectPendingMigration({
sourceHome: join(homedir(), '.kimi'),
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/test/cli/goal-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const mocks = vi.hoisted(() => {
mainEvent,
experimentalFeatures: [{ id: 'goal_command', enabled: true }],
sessions: [] as Array<{ readonly id: string; readonly workDir: string }>,
harnessCheckRuntimeEnvironment: vi.fn(async () => undefined),
};
});

Expand All @@ -122,6 +123,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => {
createKimiHarness: () => ({
homeDir: '/tmp/kimi-goal-home',
auth: { getCachedAccessToken: vi.fn() },
checkRuntimeEnvironment: mocks.harnessCheckRuntimeEnvironment,
ensureConfigFile: vi.fn(),
getConfig: vi.fn(async () => ({ providers: {}, defaultModel: 'k2', telemetry: true })),
getExperimentalFeatures: vi.fn(async () => mocks.experimentalFeatures),
Expand Down
24 changes: 24 additions & 0 deletions apps/kimi-code/test/cli/run-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const mocks = vi.hoisted(() => {
agentEvent,
mainEvent,
kimiHarnessConstructor: vi.fn(),
harnessCheckRuntimeEnvironment: vi.fn(async () => undefined),
harnessEnsureConfigFile: vi.fn(),
harnessGetConfig: vi.fn(
async (): Promise<{ providers: {}; defaultModel?: string; telemetry: boolean }> => ({
Expand Down Expand Up @@ -89,6 +90,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => {
return {
homeDir,
auth: { getCachedAccessToken: mocks.harnessGetCachedAccessToken },
checkRuntimeEnvironment: mocks.harnessCheckRuntimeEnvironment,
ensureConfigFile: mocks.harnessEnsureConfigFile,
getConfig: mocks.harnessGetConfig,
getExperimentalFeatures: mocks.harnessGetExperimentalFeatures,
Expand Down Expand Up @@ -187,6 +189,7 @@ describe('runPrompt', () => {
mocks.resolveKimiHome.mockImplementation(
(homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home',
);
mocks.harnessCheckRuntimeEnvironment.mockResolvedValue(undefined);
mocks.harnessCreatesDeviceIdOnConstruction = false;
});

Expand All @@ -199,6 +202,10 @@ describe('runPrompt', () => {
expect(mocks.kimiHarnessConstructor).toHaveBeenCalledWith(
expect.objectContaining({ skillDirs: ['/skills'], uiMode: 'print' }),
);
expect(mocks.harnessCheckRuntimeEnvironment).toHaveBeenCalledOnce();
expect(mocks.harnessCheckRuntimeEnvironment.mock.invocationCallOrder[0]).toBeLessThan(
mocks.harnessEnsureConfigFile.mock.invocationCallOrder[0]!,
);
expect(mocks.harnessCreateSession).toHaveBeenCalledWith({
workDir: process.cwd(),
model: 'k2',
Expand All @@ -214,6 +221,23 @@ describe('runPrompt', () => {
expect(mocks.harnessClose).toHaveBeenCalled();
});

it('stops prompt startup when runtime environment check fails', async () => {
const stdout = writer();
const stderr = writer();
mocks.harnessCheckRuntimeEnvironment.mockRejectedValueOnce(new Error('Git Bash missing'));

await expect(runPrompt(opts(), '1.2.3-test', { stdout, stderr })).rejects.toThrow(
'Git Bash missing',
);

expect(mocks.harnessCheckRuntimeEnvironment).toHaveBeenCalledOnce();
expect(mocks.harnessEnsureConfigFile).not.toHaveBeenCalled();
expect(mocks.harnessGetConfig).not.toHaveBeenCalled();
expect(mocks.harnessCreateSession).not.toHaveBeenCalled();
expect(mocks.session.prompt).not.toHaveBeenCalled();
expect(mocks.harnessClose).toHaveBeenCalledOnce();
});

it('uses the CLI model override when creating a fresh prompt session', async () => {
await runPrompt(opts({ model: 'kimi-code/k2.5' }), '1.2.3-test', {
stdout: { write: vi.fn(() => true) },
Expand Down
39 changes: 39 additions & 0 deletions apps/kimi-code/test/cli/run-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => {
loadTuiConfig: vi.fn(),
detectTerminalTheme: vi.fn(),
kimiHarnessConstructor: vi.fn(),
harnessCheckRuntimeEnvironment: vi.fn(async () => undefined),
harnessEnsureConfigFile: vi.fn(),
harnessGetConfig: vi.fn(async () => ({
providers: {},
Expand Down Expand Up @@ -81,6 +82,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => {
getCachedAccessToken: mocks.harnessGetCachedAccessToken,
},
ensureConfigFile: mocks.harnessEnsureConfigFile,
checkRuntimeEnvironment: mocks.harnessCheckRuntimeEnvironment,
getConfig: mocks.harnessGetConfig,
close: mocks.harnessClose,
track: mocks.harnessTrack,
Expand Down Expand Up @@ -149,6 +151,7 @@ describe('runShell', () => {
defaultModel: 'k2',
telemetry: true,
});
mocks.harnessCheckRuntimeEnvironment.mockResolvedValue(undefined);
mocks.tuiGetStartupMcpMs.mockResolvedValue(0);
mocks.tuiGetCurrentSessionId.mockReturnValue('');
mocks.tuiHasSessionContent.mockReturnValue(false);
Expand Down Expand Up @@ -191,6 +194,10 @@ describe('runShell', () => {
}),
}),
);
expect(mocks.harnessCheckRuntimeEnvironment).toHaveBeenCalledOnce();
expect(mocks.harnessCheckRuntimeEnvironment.mock.invocationCallOrder[0]).toBeLessThan(
mocks.harnessEnsureConfigFile.mock.invocationCallOrder[0]!,
);
expect(mocks.harnessEnsureConfigFile).toHaveBeenCalledOnce();
expect(mocks.harnessEnsureConfigFile.mock.invocationCallOrder[0]).toBeLessThan(
mocks.harnessGetConfig.mock.invocationCallOrder[0]!,
Expand Down Expand Up @@ -244,6 +251,38 @@ describe('runShell', () => {
});
});

it('stops startup when runtime environment check fails', async () => {
mocks.loadTuiConfig.mockResolvedValue({
theme: 'dark',
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
});
mocks.harnessCheckRuntimeEnvironment.mockRejectedValueOnce(new Error('Git Bash missing'));

await expect(
runShell(
{
session: undefined,
continue: false,
yolo: false,
auto: false,
plan: false,
model: undefined,
outputFormat: undefined,
prompt: undefined,
skillsDirs: [],
},
'1.2.3-test',
),
).rejects.toThrow('Git Bash missing');

expect(mocks.harnessCheckRuntimeEnvironment).toHaveBeenCalledOnce();
expect(mocks.harnessEnsureConfigFile).not.toHaveBeenCalled();
expect(mocks.harnessGetConfig).not.toHaveBeenCalled();
expect(mocks.kimiTuiConstructor).not.toHaveBeenCalled();
expect(mocks.tuiStart).not.toHaveBeenCalled();
});

it('tracks first launch when device id creation reports first launch', async () => {
mocks.loadTuiConfig.mockResolvedValue({
theme: 'dark',
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/rpc/core-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ type SessionAPIWithId = WithSessionId<SessionAPI>;

export interface CoreAPI extends SessionAPIWithId {
getCoreInfo: (payload: EmptyPayload) => CoreInfo;
checkRuntimeEnvironment: (payload: EmptyPayload) => void;
getExperimentalFeatures: (payload: EmptyPayload) => readonly ExperimentalFeatureState[];
getKimiConfig: (payload: GetKimiConfigPayload) => KimiConfig;
setKimiConfig: (payload: SetKimiConfigPayload) => KimiConfig;
Expand Down
26 changes: 17 additions & 9 deletions packages/agent-core/src/rpc/core-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
readonly sessions = new Map<string, Session>();
readonly telemetry: TelemetryClient;

private kaos: Promise<Kaos>;
private kaos: Promise<Kaos> | undefined;
private runtime: ToolServices | undefined;
private config: KimiConfig;
private readonly runtimeOverride: ToolServices | undefined;
Expand All @@ -148,12 +148,6 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
homeDir: this.homeDir,
configPath: options.configPath,
});
this.kaos = LocalKaos.create().catch((error: unknown) => {
if (error instanceof KaosShellNotFoundError) {
throw new KimiError(ErrorCodes.SHELL_GIT_BASH_NOT_FOUND, error.message);
}
throw error;
});
this.runtimeOverride = options.runtime;
this.runtime = options.runtime;
this.kimiRequestHeaders = options.kimiRequestHeaders;
Expand Down Expand Up @@ -182,6 +176,10 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
this.sdk = rpcClient(this);
}

async checkRuntimeEnvironment(_: EmptyPayload): Promise<void> {
await this.getKaos();
}

async createSession(input: CreateSessionPayload): Promise<SessionSummary> {
const options = input;
const workDir = requiredWorkDir('createSession', options.workDir);
Expand Down Expand Up @@ -211,7 +209,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
// ctor block throws, `session.close()` releases the sink (and mcp).
const runtime = await this.resolveRuntime(config);
const session = new Session({
kaos: (await this.kaos).withCwd(workDir),
kaos: (await this.getKaos()).withCwd(workDir),
toolServices: runtime,
config,
id,
Expand Down Expand Up @@ -299,7 +297,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
const mcpConfig = this.mergePluginMcpConfig(withCallerMcp);
const runtime = await this.resolveRuntime(config);
const session = new Session({
kaos: (await this.kaos).withCwd(summary.workDir),
kaos: (await this.getKaos()).withCwd(summary.workDir),
toolServices: runtime,
config,
id: summary.id,
Expand Down Expand Up @@ -737,6 +735,16 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
return runtime;
}

private getKaos(): Promise<Kaos> {
this.kaos ??= LocalKaos.create().catch((error: unknown) => {
if (error instanceof KaosShellNotFoundError) {
throw new KimiError(ErrorCodes.SHELL_GIT_BASH_NOT_FOUND, error.message);
}
throw error;
});
return this.kaos;
}

private resolveSessionSkillConfig(config: KimiConfig): SessionSkillConfig {
const explicitDirs = this.skillDirs.length > 0 ? this.skillDirs : undefined;
return {
Expand Down
4 changes: 4 additions & 0 deletions packages/node-sdk/src/kimi-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ export class KimiHarness {
await this.ensureConfigFileImpl();
}

async checkRuntimeEnvironment(): Promise<void> {
await this.rpc.checkRuntimeEnvironment();
}

async setConfig(patch: KimiConfigPatch): Promise<KimiConfig> {
return this.rpc.setConfig(patch);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/node-sdk/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ export abstract class SDKRpcClientBase {

protected abstract getRpc(): Promise<ResolvedCoreAPI>;

async checkRuntimeEnvironment(): Promise<void> {
const rpc = await this.getRpc();
return rpc.checkRuntimeEnvironment({});
}

async createSession(input: CreateSessionOptions): Promise<SessionSummary> {
const rpc = await this.getRpc();
const { planMode, ...coreInput } = input;
Expand Down
Loading