diff --git a/.changeset/windows-git-bash-preflight.md b/.changeset/windows-git-bash-preflight.md new file mode 100644 index 000000000..f58b78d14 --- /dev/null +++ b/.changeset/windows-git-bash-preflight.md @@ -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. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 7d41e6246..3fcd5fb1a 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -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 } = diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 4ec4330a5..3633f41cb 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -34,21 +34,6 @@ export async function runShell( runOptions: { readonly migrateOnly?: boolean } = {}, ): Promise { 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 = { @@ -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'), diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts index b7966a5a3..5373c04a1 100644 --- a/apps/kimi-code/test/cli/goal-prompt.test.ts +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -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), }; }); @@ -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), diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 9a26c5a7f..713231ee3 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -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 }> => ({ @@ -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, @@ -187,6 +189,7 @@ describe('runPrompt', () => { mocks.resolveKimiHome.mockImplementation( (homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home', ); + mocks.harnessCheckRuntimeEnvironment.mockResolvedValue(undefined); mocks.harnessCreatesDeviceIdOnConstruction = false; }); @@ -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', @@ -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) }, diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index c55a5e590..027aeb85f 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -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: {}, @@ -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, @@ -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); @@ -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]!, @@ -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', diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index da186c721..b5942c73e 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -357,6 +357,7 @@ type SessionAPIWithId = WithSessionId; 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; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 61cb01131..48fb9bacf 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -123,7 +123,7 @@ export class KimiCore implements PromisableMethods { readonly sessions = new Map(); readonly telemetry: TelemetryClient; - private kaos: Promise; + private kaos: Promise | undefined; private runtime: ToolServices | undefined; private config: KimiConfig; private readonly runtimeOverride: ToolServices | undefined; @@ -148,12 +148,6 @@ export class KimiCore implements PromisableMethods { 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; @@ -182,6 +176,10 @@ export class KimiCore implements PromisableMethods { this.sdk = rpcClient(this); } + async checkRuntimeEnvironment(_: EmptyPayload): Promise { + await this.getKaos(); + } + async createSession(input: CreateSessionPayload): Promise { const options = input; const workDir = requiredWorkDir('createSession', options.workDir); @@ -211,7 +209,7 @@ export class KimiCore implements PromisableMethods { // 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, @@ -299,7 +297,7 @@ export class KimiCore implements PromisableMethods { 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, @@ -737,6 +735,16 @@ export class KimiCore implements PromisableMethods { return runtime; } + private getKaos(): Promise { + 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 { diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 86175843c..47780b855 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -210,6 +210,10 @@ export class KimiHarness { await this.ensureConfigFileImpl(); } + async checkRuntimeEnvironment(): Promise { + await this.rpc.checkRuntimeEnvironment(); + } + async setConfig(patch: KimiConfigPatch): Promise { return this.rpc.setConfig(patch); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index c3a220ea8..ddd384f9c 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -99,6 +99,11 @@ export abstract class SDKRpcClientBase { protected abstract getRpc(): Promise; + async checkRuntimeEnvironment(): Promise { + const rpc = await this.getRpc(); + return rpc.checkRuntimeEnvironment({}); + } + async createSession(input: CreateSessionOptions): Promise { const rpc = await this.getRpc(); const { planMode, ...coreInput } = input;