diff --git a/.changeset/unified-system-dependency-check.md b/.changeset/unified-system-dependency-check.md new file mode 100644 index 00000000..e1444685 --- /dev/null +++ b/.changeset/unified-system-dependency-check.md @@ -0,0 +1,10 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kaos": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Add a unified system-dependency check for ripgrep, fd, and the shell. Kimi Code CLI now starts on Windows even when Git Bash is missing (the Bash tool is omitted and the model is told why, instead of the CLI crashing), warns at startup when `fd` is unavailable outside a git repository, and reports the health of all three external tools in `/status`. Dependency metadata lives in a single declarative registry so detection and messaging stay consistent. + +BREAKING: the now-unused `KaosShellNotFoundError` (`@moonshot-ai/kaos`) and `ErrorCodes.SHELL_GIT_BASH_NOT_FOUND` (`@moonshot-ai/agent-core`, re-exported by `@moonshot-ai/kimi-code-sdk`) are removed, since a missing shell is no longer a hard failure. diff --git a/apps/kimi-code/src/cli/system-deps/check.ts b/apps/kimi-code/src/cli/system-deps/check.ts new file mode 100644 index 00000000..61549932 --- /dev/null +++ b/apps/kimi-code/src/cli/system-deps/check.ts @@ -0,0 +1,129 @@ +/** + * System-dependency evaluation. + * + * `evaluateDependencies` is a pure function of an injected {@link DependencyProbe} + * so the resolution/warning logic can be unit-tested without spawning processes. + * `isRipgrepOnSystemPath` is the only side-effecting helper; the shell and fd + * facts are gathered by the caller (shell from `harness.getEnvironment()`, fd + * from the TUI's existing `detectFdPath()` result) so we never re-probe. + */ + +import { spawnSync } from 'node:child_process'; + +import type { Environment } from '@moonshot-ai/kimi-code-sdk'; + +import { SYSTEM_DEPENDENCIES, type DependencyId, type SystemDependency } from './registry'; + +/** Raw, already-gathered facts about the current environment. */ +export interface DependencyProbe { + readonly environment: Environment; + /** Result of the TUI's `detectFdPath()` (reused, not re-probed). */ + readonly fdAvailable: boolean; + /** Whether `rg` resolves on the system PATH right now. */ + readonly rgOnSystemPath: boolean; + /** Whether this platform/arch is one the ripgrep bootstrapper can download for. */ + readonly rgBootstrappable: boolean; + /** Whether the working directory is a git repository (fd fallback scope). */ + readonly isGitRepo: boolean; +} + +export interface DependencyStatus { + readonly dependency: SystemDependency; + readonly available: boolean; + /** One-line, user-facing detail (resolved source, or why/how to fix). */ + readonly detail: string; + /** Whether this missing dependency warrants a startup warning right now. */ + readonly shouldWarnAtStartup: boolean; +} + +/** Probe whether `rg` is resolvable on the system PATH (cheap, ~ms). */ +export function isRipgrepOnSystemPath(): boolean { + try { + return spawnSync('rg', ['--version'], { stdio: 'ignore' }).status === 0; + } catch { + return false; + } +} + +/** + * Whether the ripgrep bootstrapper can download a prebuilt binary for this + * host. Mirrors `rg-locator`'s `detectTarget()` support matrix (darwin / linux + * / win32 on x64 / arm64); on anything else the auto-download throws, so a + * missing system `rg` is genuinely unavailable rather than self-healing. + * Keep in sync with packages/agent-core/src/tools/support/rg-locator.ts. + */ +export function isRipgrepBootstrapSupported(): boolean { + const platformOk = + process.platform === 'darwin' || + process.platform === 'linux' || + process.platform === 'win32'; + const archOk = process.arch === 'x64' || process.arch === 'arm64'; + return platformOk && archOk; +} + +/** + * `available` means "the capability this dependency provides is currently + * satisfied" — directly, by auto-bootstrap, or by a fallback — NOT merely + * "the binary is on PATH". Kept uniform across dependencies so the `/status` + * marker never contradicts its own detail text. + */ +function isAvailable(id: DependencyId, probe: DependencyProbe): boolean { + switch (id) { + case 'ripgrep': + // On PATH now, or auto-downloadable on this platform — otherwise the + // first Grep call will fail, so it is genuinely unavailable. + return probe.rgOnSystemPath || probe.rgBootstrappable; + case 'fd': + // The binary, or the `git ls-files` fallback inside a git repository. + return probe.fdAvailable || probe.isGitRepo; + case 'shell': + return probe.environment.shellAvailable !== false; + } +} + +function detailFor( + dep: SystemDependency, + available: boolean, + probe: DependencyProbe, +): string { + switch (dep.id) { + case 'ripgrep': + if (probe.rgOnSystemPath) return 'Found on system PATH.'; + return probe.rgBootstrappable + ? 'Not on PATH — downloaded and cached on first use.' + : `Not on PATH and no prebuilt binary for this platform. ${dep.fixHint}`; + case 'fd': + if (probe.fdAvailable) return 'Found on system PATH.'; + return probe.isGitRepo + ? 'Not installed; using the `git ls-files` fallback in this git repository.' + : `Missing and not in a git repository. ${dep.fixHint}`; + case 'shell': + if (available) return `Using ${probe.environment.shellPath}.`; + return probe.environment.shellUnavailableReason ?? dep.fixHint; + } +} + +function shouldWarn(dep: SystemDependency, available: boolean, probe: DependencyProbe): boolean { + if (available) return false; + switch (dep.startupWarning) { + case 'always': + return true; + case 'outside-git-repo': + return !probe.isGitRepo; + case 'never': + return false; + } +} + +/** Pure evaluation of every registered dependency against the probe. */ +export function evaluateDependencies(probe: DependencyProbe): DependencyStatus[] { + return SYSTEM_DEPENDENCIES.map((dependency) => { + const available = isAvailable(dependency.id, probe); + return { + dependency, + available, + detail: detailFor(dependency, available, probe), + shouldWarnAtStartup: shouldWarn(dependency, available, probe), + }; + }); +} diff --git a/apps/kimi-code/src/cli/system-deps/registry.ts b/apps/kimi-code/src/cli/system-deps/registry.ts new file mode 100644 index 00000000..83ed444d --- /dev/null +++ b/apps/kimi-code/src/cli/system-deps/registry.ts @@ -0,0 +1,87 @@ +/** + * System-dependency registry — the single source of truth for the external + * command-line tools Kimi Code CLI relies on (`rg`, `fd`, and a POSIX shell). + * + * Historically each dependency was probed, degraded, and described in its own + * corner of the codebase (ripgrep in `rg-locator`, fd in `fd-detect`, the + * shell in KAOS environment detection). That made "is X a dependency, and what + * happens when it is missing?" impossible to answer in one place. This module + * declares each dependency once — its purpose, whether it is required, whether + * the CLI can self-heal by downloading it, its graceful-degradation path, and + * when its absence should warn the user. `check.ts` and `report.ts` read from + * here so detection and messaging stay consistent and new dependencies are a + * one-line addition. + */ + +export type DependencyId = 'ripgrep' | 'fd' | 'shell'; + +export type DependencyRequirement = 'required' | 'optional'; + +/** + * When a missing dependency should surface a startup warning: + * - `always` — whenever it is not available (shell: Bash tool + * dropped; ripgrep: not on PATH and no prebuilt + * binary for this platform). + * - `outside-git-repo` — only when its fallback is unavailable (fd: the + * `git ls-files` fallback only covers git repos). + * - `never` — never warn, even when missing. + */ +export type StartupWarningPolicy = 'always' | 'outside-git-repo' | 'never'; + +export interface SystemDependency { + readonly id: DependencyId; + readonly displayName: string; + readonly purpose: string; + readonly requirement: DependencyRequirement; + /** Whether the CLI can fetch this binary on demand (only ripgrep, today). */ + readonly autoBootstrap: boolean; + /** Human note on the graceful-degradation path, if any. */ + readonly fallback?: string; + readonly startupWarning: StartupWarningPolicy; + /** Short, actionable install hint, aligned with `rgUnavailableMessage`. */ + readonly fixHint: string; +} + +export const SYSTEM_DEPENDENCIES: readonly SystemDependency[] = [ + { + id: 'ripgrep', + displayName: 'ripgrep (rg)', + purpose: 'Powers the Grep tool and file-content search.', + requirement: 'required', + autoBootstrap: true, + // Self-heals on supported platforms (auto-download), so a warning only + // fires when it is neither on PATH nor downloadable for this platform. + startupWarning: 'always', + fixHint: + 'Install ripgrep: macOS `brew install ripgrep`, Ubuntu `sudo apt-get install ripgrep`, others https://github.com/BurntSushi/ripgrep#installation.', + }, + { + id: 'fd', + displayName: 'fd', + purpose: 'Cross-directory fuzzy file search for `@` mentions.', + requirement: 'optional', + autoBootstrap: false, + fallback: 'Inside a git repository, `git ls-files` still powers `@` completion.', + startupWarning: 'outside-git-repo', + fixHint: 'Install fd: macOS `brew install fd`, Ubuntu `sudo apt-get install fd-find`.', + }, + { + id: 'shell', + displayName: 'shell (Git Bash on Windows)', + purpose: 'Required by the Bash tool to run shell commands.', + requirement: 'required', + autoBootstrap: false, + fallback: 'The Bash tool is omitted; file, search, and planning tools still work.', + startupWarning: 'always', + fixHint: + 'Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe.', + }, +]; + +export function getDependency(id: DependencyId): SystemDependency { + const dep = SYSTEM_DEPENDENCIES.find((d) => d.id === id); + if (dep === undefined) { + throw new Error(`Unknown system dependency: ${id}`); + } + return dep; +} diff --git a/apps/kimi-code/src/cli/system-deps/report.ts b/apps/kimi-code/src/cli/system-deps/report.ts new file mode 100644 index 00000000..8824d6cc --- /dev/null +++ b/apps/kimi-code/src/cli/system-deps/report.ts @@ -0,0 +1,43 @@ +/** + * System-dependency rendering — turns evaluated {@link DependencyStatus} into + * (1) plain startup-warning strings and (2) a `/status` report section. Kept + * separate from `check.ts` so the formatting is pure and snapshot-friendly. + */ + +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +import type { DependencyStatus } from './check'; + +/** + * One concise warning line per missing dependency that matters right now + * (shell unavailable, or fd missing outside a git repo). Returned plain so the + * caller can route each through `showStatus(..., warning)`. + */ +export function startupDependencyWarnings(statuses: readonly DependencyStatus[]): string[] { + return statuses + .filter((status) => status.shouldWarnAtStartup) + .map((status) => `${status.dependency.displayName}: ${status.detail}`); +} + +/** A "System dependencies" section for the `/status` report. */ +export function buildDependencyReportLines(options: { + readonly colors: ColorPalette; + readonly statuses: readonly DependencyStatus[]; +}): string[] { + const { colors, statuses } = options; + const accent = chalk.hex(colors.primary).bold; + const value = chalk.hex(colors.text); + const muted = chalk.hex(colors.textDim); + + const labelWidth = Math.max(...statuses.map((s) => s.dependency.displayName.length)); + const lines: string[] = [accent('System dependencies')]; + for (const status of statuses) { + const marker = chalk.hex(status.available ? colors.success : colors.warning)('●'); + const label = value(status.dependency.displayName.padEnd(labelWidth, ' ')); + const tag = muted(`(${status.dependency.requirement})`); + lines.push(` ${marker} ${label} ${tag} ${muted(status.detail)}`); + } + return lines; +} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 90250f87..ae75ffa8 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -2,6 +2,8 @@ import type { Component, Focusable } from '@earendil-works/pi-tui'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { DependencyStatus } from '#/cli/system-deps/check'; + import type { Theme } from '../theme'; import type { ResolvedTheme } from '../theme/colors'; import { @@ -105,6 +107,9 @@ export interface SlashCommandHost { restoreEditor(): void; restoreInputText(text: string): void; + // System dependencies (ripgrep / fd / shell) — unified status for `/status`. + collectSystemDependencyStatuses(): Promise; + // Session requireSession(): Session; switchToSession(session: Session, message: string): Promise; diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index a5dd4795..be9684ab 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -2,6 +2,7 @@ import { release as osRelease, type as osType } from 'node:os'; import type { McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk'; +import { buildDependencyReportLines } from '#/cli/system-deps/report'; import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel'; import { buildStatusReportLines } from '../components/messages/status-panel'; import { buildUsageReportLines, UsagePanelComponent, type ManagedUsageReport } from '../components/messages/usage-panel'; @@ -103,9 +104,10 @@ export async function showUsage(host: SlashCommandHost): Promise { } export async function showStatusReport(host: SlashCommandHost): Promise { - const [runtimeStatus, managedUsage] = await Promise.all([ + const [runtimeStatus, managedUsage, dependencyStatuses] = await Promise.all([ loadRuntimeStatusReport(host), loadManagedUsageReport(host), + host.collectSystemDependencyStatuses().catch(() => []), ]); const appState = host.state.appState; const lines = buildStatusReportLines({ @@ -127,6 +129,15 @@ export async function showStatusReport(host: SlashCommandHost): Promise { managedUsage: managedUsage?.usage, managedUsageError: managedUsage?.error, }); + if (dependencyStatuses.length > 0) { + lines.push(''); + lines.push( + ...buildDependencyReportLines({ + colors: host.state.theme.colors, + statuses: dependencyStatuses, + }), + ); + } const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ' Status '); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index daa98c4a..fab920c9 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -30,6 +30,13 @@ import { createGitLsFilesCache } from '#/utils/git/git-ls-files'; import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history'; import { getInputHistoryFile } from '#/utils/paths'; import { detectFdPath } from '#/utils/process/fd-detect'; +import { + evaluateDependencies, + isRipgrepBootstrapSupported, + isRipgrepOnSystemPath, + type DependencyStatus, +} from '#/cli/system-deps/check'; +import { startupDependencyWarnings } from '#/cli/system-deps/report'; import { BUILTIN_SLASH_COMMANDS, @@ -404,6 +411,7 @@ export class KimiTUI { this.mountFooter(); this.renderWelcome(); this.setupAutocomplete(); + await this.showSystemDependencyWarnings(); void this.loadPersistedInputHistory(); this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); @@ -411,6 +419,34 @@ export class KimiTUI { return shouldReplayHistory; } + /** + * Unified system-dependency check (ripgrep / fd / shell). Reads the resolved + * shell state from the core's single source of truth via the harness, reuses + * the already-probed `fdPath`, and reports against the declarative registry — + * so `/status` and the startup warnings stay consistent. + */ + async collectSystemDependencyStatuses(): Promise { + const environment = await this.harness.getEnvironment(); + return evaluateDependencies({ + environment, + fdAvailable: this.fdPath !== null, + rgOnSystemPath: isRipgrepOnSystemPath(), + rgBootstrappable: isRipgrepBootstrapSupported(), + isGitRepo: this.gitLsFilesCache.isGitRepo(), + }); + } + + private async showSystemDependencyWarnings(): Promise { + try { + const statuses = await this.collectSystemDependencyStatuses(); + for (const message of startupDependencyWarnings(statuses)) { + this.showStatus(message, this.state.theme.colors.warning); + } + } catch { + // A dependency check must never block or crash startup. + } + } + private startEventLoop(): void { this.state.ui.start(); this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index 8d674d44..7e9429c9 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -306,18 +306,15 @@ describe('main entry command handling', () => { }); it('formats Kimi startup errors with structured fields', () => { - const error = new KimiError( - ErrorCodes.SHELL_GIT_BASH_NOT_FOUND, - 'Git Bash was not found on this Windows host. Checked: C:\\Program Files\\Git\\bin\\bash.exe.', - ); + const error = new KimiError(ErrorCodes.SESSION_NOT_FOUND, 'Session "abc" was not found.'); const red = (text: string): string => `\u001B[31m${text}\u001B[39m`; expect(formatStartupError(error, { errorStyle: red })).toBe( [ - '\u001B[31merror: Git Bash not found\u001B[39m', + '\u001B[31merror: Session not found\u001B[39m', '', '\u001B[31mmessage:\u001B[39m', - '\u001B[31mGit Bash was not found on this Windows host. Checked: C:\\Program Files\\Git\\bin\\bash.exe.\u001B[39m', + '\u001B[31mSession \"abc\" was not found.\u001B[39m', '', ].join('\n'), ); diff --git a/apps/kimi-code/test/cli/system-deps/check.test.ts b/apps/kimi-code/test/cli/system-deps/check.test.ts new file mode 100644 index 00000000..6b50a39d --- /dev/null +++ b/apps/kimi-code/test/cli/system-deps/check.test.ts @@ -0,0 +1,103 @@ +import type { Environment } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { evaluateDependencies, type DependencyProbe } from '#/cli/system-deps/check'; + +const POSIX_ENV: Environment = { + osKind: 'macOS', + osArch: 'arm64', + osVersion: 'test', + shellName: 'bash', + shellPath: '/bin/bash', +}; + +const WINDOWS_NO_SHELL: Environment = { + osKind: 'Windows', + osArch: 'x64', + osVersion: '10.0.22631.0', + shellName: 'bash', + shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe', + shellAvailable: false, + shellUnavailableReason: 'Git Bash was not found on this Windows host.', +}; + +function probe(overrides: Partial = {}): DependencyProbe { + return { + environment: POSIX_ENV, + fdAvailable: true, + rgOnSystemPath: true, + rgBootstrappable: true, + isGitRepo: true, + ...overrides, + }; +} + +function statusFor(id: string, p: DependencyProbe) { + const status = evaluateDependencies(p).find((s) => s.dependency.id === id); + if (status === undefined) throw new Error(`no status for ${id}`); + return status; +} + +describe('evaluateDependencies', () => { + it('reports ripgrep as available even when not on PATH (auto-downloads)', () => { + const onPath = statusFor('ripgrep', probe({ rgOnSystemPath: true })); + expect(onPath.available).toBe(true); + expect(onPath.shouldWarnAtStartup).toBe(false); + expect(onPath.detail).toContain('system PATH'); + + const offPath = statusFor('ripgrep', probe({ rgOnSystemPath: false })); + expect(offPath.available).toBe(true); + expect(offPath.shouldWarnAtStartup).toBe(false); + expect(offPath.detail).toContain('downloaded'); + }); + + it('warns for ripgrep when not on PATH and not bootstrappable for this platform', () => { + const status = statusFor( + 'ripgrep', + probe({ rgOnSystemPath: false, rgBootstrappable: false }), + ); + expect(status.available).toBe(false); + expect(status.shouldWarnAtStartup).toBe(true); + expect(status.detail).toContain('no prebuilt binary'); + }); + + it('warns for fd missing outside a git repository', () => { + const status = statusFor('fd', probe({ fdAvailable: false, isGitRepo: false })); + expect(status.available).toBe(false); + expect(status.shouldWarnAtStartup).toBe(true); + expect(status.detail).toContain('not in a git repository'); + }); + + it('stays quiet for fd missing inside a git repository (git ls-files fallback)', () => { + const status = statusFor('fd', probe({ fdAvailable: false, isGitRepo: true })); + // The capability is satisfied via the fallback, so it counts as available. + expect(status.available).toBe(true); + expect(status.shouldWarnAtStartup).toBe(false); + expect(status.detail).toContain('git ls-files'); + }); + + it('stays quiet when fd is available outside a git repository', () => { + const status = statusFor('fd', probe({ fdAvailable: true, isGitRepo: false })); + expect(status.available).toBe(true); + expect(status.shouldWarnAtStartup).toBe(false); + }); + + it('warns when the shell is unavailable and surfaces the probe reason', () => { + const status = statusFor('shell', probe({ environment: WINDOWS_NO_SHELL })); + expect(status.available).toBe(false); + expect(status.shouldWarnAtStartup).toBe(true); + expect(status.detail).toContain('Git Bash was not found'); + }); + + it('treats a shell as available when shellAvailable is undefined (POSIX)', () => { + const status = statusFor('shell', probe({ environment: POSIX_ENV })); + expect(status.available).toBe(true); + expect(status.shouldWarnAtStartup).toBe(false); + expect(status.detail).toContain('/bin/bash'); + }); + + it('covers every registered dependency exactly once', () => { + const ids = evaluateDependencies(probe()).map((s) => s.dependency.id); + expect(ids).toEqual(['ripgrep', 'fd', 'shell']); + }); +}); diff --git a/apps/kimi-code/test/cli/system-deps/report.test.ts b/apps/kimi-code/test/cli/system-deps/report.test.ts new file mode 100644 index 00000000..04471776 --- /dev/null +++ b/apps/kimi-code/test/cli/system-deps/report.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import type { DependencyStatus } from '#/cli/system-deps/check'; +import { getDependency } from '#/cli/system-deps/registry'; +import { buildDependencyReportLines, startupDependencyWarnings } from '#/cli/system-deps/report'; + +function status(id: 'ripgrep' | 'fd' | 'shell', over: Partial): DependencyStatus { + return { + dependency: getDependency(id), + available: true, + detail: 'ok', + shouldWarnAtStartup: false, + ...over, + }; +} + +// Minimal palette — only the fields the renderer reads. +const COLORS = { + primary: '#ffffff', + text: '#ffffff', + textDim: '#888888', + success: '#00ff00', + warning: '#ffaa00', + error: '#ff0000', +} as never; + +describe('startupDependencyWarnings', () => { + it('emits only dependencies flagged to warn at startup', () => { + const warnings = startupDependencyWarnings([ + status('ripgrep', { shouldWarnAtStartup: false }), + status('fd', { shouldWarnAtStartup: true, available: false, detail: 'missing outside git' }), + status('shell', { shouldWarnAtStartup: true, available: false, detail: 'no git bash' }), + ]); + expect(warnings).toHaveLength(2); + expect(warnings[0]).toContain('fd'); + expect(warnings[0]).toContain('missing outside git'); + expect(warnings[1]).toContain('shell'); + }); + + it('returns nothing when all dependencies are healthy', () => { + expect( + startupDependencyWarnings([status('ripgrep', {}), status('fd', {}), status('shell', {})]), + ).toEqual([]); + }); +}); + +describe('buildDependencyReportLines', () => { + it('renders a header plus one line per dependency', () => { + const lines = buildDependencyReportLines({ + colors: COLORS, + statuses: [status('ripgrep', {}), status('fd', { available: false }), status('shell', {})], + }); + expect(lines[0]).toContain('System dependencies'); + expect(lines).toHaveLength(4); + expect(lines.join('\n')).toContain('ripgrep (rg)'); + expect(lines.join('\n')).toContain('fd'); + expect(lines.join('\n')).toContain('shell'); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index ca4874ae..cf34fd36 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MigrationPlan } from "@moonshot-ai/migration-legacy"; import { log, type GoalSnapshot } from "@moonshot-ai/kimi-code-sdk"; @@ -25,6 +25,31 @@ vi.mock("#/tui/commands/prompts", async (importOriginal) => { return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() }; }); +// Make the system-dependency probe deterministic across machines: fd presence +// and git-repo detection are mocked so tests don't depend on the host having +// (or not having) fd installed, or running inside a git checkout. +const moduleMocks = vi.hoisted(() => ({ + detectFdPath: vi.fn(() => "fd" as string | null), + createGitLsFilesCache: vi.fn(), +})); + +function makeGitCache(isGitRepo: boolean) { + return { isGitRepo: () => isGitRepo, getSnapshot: () => null, list: () => null }; +} + +vi.mock("#/utils/process/fd-detect", () => ({ detectFdPath: moduleMocks.detectFdPath })); +vi.mock("#/utils/git/git-ls-files", () => ({ + createGitLsFilesCache: moduleMocks.createGitLsFilesCache, +})); + +const POSIX_ENVIRONMENT = { + osKind: "macOS", + osArch: "arm64", + osVersion: "test", + shellName: "bash", + shellPath: "/bin/bash", +}; + interface StartupDriver { state: TUIState; init(): Promise; @@ -167,6 +192,7 @@ function makeHarness(session = makeSession(), overrides: Record track: vi.fn(), setTelemetryContext: vi.fn(), getExperimentalFlags: vi.fn(async () => ({})), + getEnvironment: vi.fn(async () => POSIX_ENVIRONMENT), auth: { status: vi.fn(async () => ({ providers: [] })), login: vi.fn(async () => {}), @@ -203,6 +229,13 @@ function captureInputListeners(driver: StartupDriver) { } describe("KimiTUI startup", () => { + beforeEach(() => { + // Healthy defaults: fd present + inside a git repo → no dependency warnings, + // so the pre-existing startup tests are undisturbed. + moduleMocks.detectFdPath.mockReturnValue("fd"); + moduleMocks.createGitLsFilesCache.mockReturnValue(makeGitCache(true)); + }); + it("creates a fresh session from startup flags and syncs runtime state", async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ @@ -897,6 +930,59 @@ describe("KimiTUI startup", () => { expect(uiContainsFooter(driver)).toBe(true); }); + + it("warns at startup when fd is missing outside a git repository", async () => { + moduleMocks.detectFdPath.mockReturnValue(null); + moduleMocks.createGitLsFilesCache.mockReturnValue(makeGitCache(false)); + const driver = makeDriver(makeHarness(), makeStartupInput()) as unknown as MigrateExitDriver; + const showStatus = vi + .spyOn(driver as unknown as KimiTUI, "showStatus") + .mockImplementation(() => {}); + + await driver.initMainTui(); + + const fdWarning = showStatus.mock.calls.find(([message]) => message.includes("fd")); + expect(fdWarning).toBeDefined(); + expect(fdWarning?.[1]).toBe(driver.state.theme.colors.warning); + }); + + it("does not warn about fd when missing inside a git repository", async () => { + moduleMocks.detectFdPath.mockReturnValue(null); + moduleMocks.createGitLsFilesCache.mockReturnValue(makeGitCache(true)); + const driver = makeDriver(makeHarness(), makeStartupInput()) as unknown as MigrateExitDriver; + const showStatus = vi + .spyOn(driver as unknown as KimiTUI, "showStatus") + .mockImplementation(() => {}); + + await driver.initMainTui(); + + expect(showStatus.mock.calls.filter(([message]) => message.includes("fd"))).toEqual([]); + }); + + it("warns at startup when the shell is unavailable", async () => { + const harness = makeHarness(makeSession(), { + getEnvironment: vi.fn(async () => ({ + osKind: "Windows", + osArch: "x64", + osVersion: "10.0.22631.0", + shellName: "bash", + shellPath: "C:\\Program Files\\Git\\bin\\bash.exe", + shellAvailable: false, + shellUnavailableReason: "Git Bash was not found on this Windows host.", + })), + }); + const driver = makeDriver(harness, makeStartupInput()) as unknown as MigrateExitDriver; + const showStatus = vi + .spyOn(driver as unknown as KimiTUI, "showStatus") + .mockImplementation(() => {}); + + await driver.initMainTui(); + + const shellWarning = showStatus.mock.calls.find(([message]) => message.includes("shell")); + expect(shellWarning).toBeDefined(); + expect(shellWarning?.[0]).toContain("Git Bash was not found"); + expect(shellWarning?.[1]).toBe(driver.state.theme.colors.warning); + }); }); function uiContainsFooter(driver: StartupDriver): boolean { diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md index b0731a05..6cd18878 100644 --- a/docs/en/guides/getting-started.md +++ b/docs/en/guides/getting-started.md @@ -52,6 +52,16 @@ Or with pnpm: pnpm add -g @moonshot-ai/kimi-code ``` +## System dependencies + +Beyond Node.js, Kimi Code CLI uses a few external command-line tools. None of them block startup — the CLI degrades gracefully and tells you when one is missing. You can review their status any time with `/status`. + +| Tool | Used for | If missing | +| --- | --- | --- | +| `ripgrep` (`rg`) | The `Grep` tool and file-content search | Auto-downloaded on first use. If `rg` is already on your `PATH`, that copy is used instead. | +| `fd` | Cross-directory fuzzy file search for `@` mentions | Optional. Inside a git repository, `git ls-files` still powers `@` completion; outside one, you get a startup warning. Install with `brew install fd` (macOS) or `sudo apt-get install fd-find` (Ubuntu). | +| Shell (Git Bash on Windows) | The `Bash` tool, which runs shell commands | On Windows the `Bash` tool runs through Git Bash. If it is not installed and `KIMI_SHELL_PATH` does not point to a `bash.exe`, the CLI still starts, but the `Bash` tool is omitted until a shell is available. Install [Git for Windows](https://gitforwindows.org/) to enable it. | + ## Upgrade and uninstall After installation, verify that the executable is ready: diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index f8544bc5..d76f755f 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -37,7 +37,7 @@ File tools handle reading, writing, and searching the local filesystem, and are **`Bash`** is the most versatile and the most permission-sensitive tool. It accepts `command` (required) along with the optional `cwd` (working directory), `timeout` (milliseconds), `description` (background task description, required when `run_in_background=true`), `run_in_background` (whether to run as a background task), and `disable_timeout` (whether to disable the timeout for a background task). The foreground `timeout` defaults to 60 seconds and is capped at 5 minutes; the background `timeout` defaults to 10 minutes and is also capped at 10 minutes. -In foreground mode `Bash` blocks the current turn until the command finishes or times out; in background mode it returns a task ID immediately. Background tasks time out after 10 minutes by default; if a task really needs to run without a timeout, set `disable_timeout=true`. When the task completes, fails, or is stopped, the agent is automatically notified to continue processing; during execution, the result can also be inspected explicitly via `TaskOutput`. stdin is always closed, so interactive commands receive EOF immediately. A two-phase termination strategy (SIGTERM → 5-second grace period → SIGKILL) ensures processes terminate reliably after a timeout. On Windows, Git Bash is used as the shell by default. +In foreground mode `Bash` blocks the current turn until the command finishes or times out; in background mode it returns a task ID immediately. Background tasks time out after 10 minutes by default; if a task really needs to run without a timeout, set `disable_timeout=true`. When the task completes, fails, or is stopped, the agent is automatically notified to continue processing; during execution, the result can also be inspected explicitly via `TaskOutput`. stdin is always closed, so interactive commands receive EOF immediately. A two-phase termination strategy (SIGTERM → 5-second grace period → SIGKILL) ensures processes terminate reliably after a timeout. On Windows, Git Bash is used as the shell by default; if Git Bash is not installed and `KIMI_SHELL_PATH` does not point to a `bash.exe`, Kimi Code CLI still starts, but the `Bash` tool is omitted until a shell is available. ## Network tools diff --git a/docs/zh/guides/getting-started.md b/docs/zh/guides/getting-started.md index fb5c2d90..55457e6c 100644 --- a/docs/zh/guides/getting-started.md +++ b/docs/zh/guides/getting-started.md @@ -52,6 +52,16 @@ npm install -g @moonshot-ai/kimi-code pnpm add -g @moonshot-ai/kimi-code ``` +## 系统依赖 + +除 Node.js 外,Kimi Code CLI 还会用到几个外部命令行工具。它们都不会阻塞启动——缺失时 CLI 会优雅降级并明确提示。你可以随时用 `/status` 查看它们的状态。 + +| 工具 | 用途 | 缺失时 | +| --- | --- | --- | +| `ripgrep`(`rg`) | `Grep` 工具与文件内容搜索 | 首次使用时自动下载。如果 `PATH` 中已有 `rg`,则优先使用系统版本。 | +| `fd` | `@` 提及的跨目录模糊文件搜索 | 可选。在 git 仓库内,`git ls-files` 仍可支撑 `@` 补全;在 git 仓库外则会给出启动警告。可通过 `brew install fd`(macOS)或 `sudo apt-get install fd-find`(Ubuntu)安装。 | +| Shell(Windows 上为 Git Bash) | `Bash` 工具,用于执行 shell 命令 | Windows 上 `Bash` 工具通过 Git Bash 运行。如果未安装且 `KIMI_SHELL_PATH` 未指向 `bash.exe`,CLI 仍会启动,但在 shell 可用前会省略 `Bash` 工具。安装 [Git for Windows](https://gitforwindows.org/) 即可启用。 | + ## 升级与卸载 安装完成后,验证可执行文件是否就绪: diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 64c4590b..254aecbc 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -37,7 +37,7 @@ **`Bash`** 是最通用也是权限要求最严格的工具。它接受 `command`(必填)以及可选的 `cwd`(工作目录)、`timeout`(毫秒)、`description`(后台任务描述,`run_in_background=true` 时必填)、`run_in_background`(是否以后台任务运行)和 `disable_timeout`(后台任务是否取消超时)。前台 `timeout` 默认 60 秒、最长 5 分钟;后台 `timeout` 默认 10 分钟、最长 10 分钟。 -前台模式下 `Bash` 会阻塞当前轮次,直到命令结束或超时;后台模式会立即返回任务 ID。后台任务默认 10 分钟后超时;如果确实需要让任务不受超时限制,可以设置 `disable_timeout=true`。任务结束、失败或被停止时会自动通知 Agent 继续处理,过程中也可通过 `TaskOutput` 主动查看结果。stdin 始终被关闭,交互式命令会立即收到 EOF。两阶段终止策略(SIGTERM → 5 秒宽限期 → SIGKILL)确保超时后进程能可靠结束。Windows 平台下默认使用 Git Bash 作为 shell。 +前台模式下 `Bash` 会阻塞当前轮次,直到命令结束或超时;后台模式会立即返回任务 ID。后台任务默认 10 分钟后超时;如果确实需要让任务不受超时限制,可以设置 `disable_timeout=true`。任务结束、失败或被停止时会自动通知 Agent 继续处理,过程中也可通过 `TaskOutput` 主动查看结果。stdin 始终被关闭,交互式命令会立即收到 EOF。两阶段终止策略(SIGTERM → 5 秒宽限期 → SIGKILL)确保超时后进程能可靠结束。Windows 平台下默认使用 Git Bash 作为 shell;如果未安装 Git Bash,且 `KIMI_SHELL_PATH` 没有指向 `bash.exe`,Kimi Code CLI 仍会启动,但在 shell 可用前会省略 `Bash` 工具。 ## 网络类 diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index ff8af558..22a6144e 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -378,9 +378,10 @@ export class ToolManager { new b.EditTool(kaos, workspace), new b.GrepTool(kaos, workspace), new b.GlobTool(kaos, workspace), - new b.BashTool(kaos, cwd, background, { - allowBackground, - }), + kaos.osEnv.shellAvailable !== false && + new b.BashTool(kaos, cwd, background, { + allowBackground, + }), (modelCapabilities.image_in || modelCapabilities.video_in) && new b.ReadMediaFileTool(kaos, workspace, modelCapabilities, videoUploader), new b.EnterPlanModeTool(this.agent), diff --git a/packages/agent-core/src/errors/codes.ts b/packages/agent-core/src/errors/codes.ts index 80dd108f..f24ff10f 100644 --- a/packages/agent-core/src/errors/codes.ts +++ b/packages/agent-core/src/errors/codes.ts @@ -74,8 +74,6 @@ export const ErrorCodes = { REQUEST_WORK_DIR_REQUIRED: 'request.work_dir_required', REQUEST_PROMPT_INPUT_EMPTY: 'request.prompt_input_empty', - SHELL_GIT_BASH_NOT_FOUND: 'shell.git_bash_not_found', - NOT_IMPLEMENTED: 'not_implemented', INTERNAL: 'internal', } as const; @@ -429,13 +427,6 @@ export const KIMI_ERROR_INFO = { action: 'Provide non-empty prompt input.', }, - 'shell.git_bash_not_found': { - title: 'Git Bash not found', - retryable: false, - public: true, - action: 'Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe.', - }, - not_implemented: { title: 'Not implemented', retryable: false, diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 8c28740f..ac827b51 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -79,3 +79,4 @@ export type { ExecutableToolSuccessResult, ExecutableToolErrorResult, } from './loop/types'; +export type { Environment } from '@moonshot-ai/kaos'; diff --git a/packages/agent-core/src/profile/default/system.md b/packages/agent-core/src/profile/default/system.md index 0436f944..cccd7e73 100644 --- a/packages/agent-core/src/profile/default/system.md +++ b/packages/agent-core/src/profile/default/system.md @@ -38,8 +38,12 @@ When building something from scratch, you should: Always use tools to implement your code changes: - Use `Write` to create or overwrite source files. Code that only appears in your text response is NOT saved to the file system and will not take effect. +{% if KIMI_SHELL_AVAILABLE == "true" %} - Use `Bash` to run and test your code after writing it. - Iterate: if tests fail, read the error, fix the code with `Write` or `Edit`, and re-test with `Bash`. +{% else %} +- The `Bash` tool is unavailable in this environment, so you cannot run or test code through a shell. Make changes carefully with `Write` / `Edit`, and ask the user to run any commands that need a shell. +{% endif %} When working on an existing codebase, you should: @@ -69,17 +73,24 @@ The user may ask you to research on certain topics, process or generate certain ## Operating System -You are running on **{{ KIMI_OS }}**. The Bash tool executes commands using **{{ KIMI_SHELL }}**. +You are running on **{{ KIMI_OS }}**. +{% if KIMI_SHELL_AVAILABLE == "true" %} + +The Bash tool executes commands using **{{ KIMI_SHELL }}**. {% if KIMI_OS == "Windows" %} IMPORTANT: You are on Windows. The Bash tool runs through Git Bash, so use Unix shell syntax inside Bash commands — `/dev/null` not `NUL`, and forward slashes in paths. For file operations, always prefer the built-in tools (Read, Write, Edit, Glob, Grep) over Bash commands — they work reliably across all platforms. {% endif %} +{% else %} + +The Bash tool is unavailable: {{ KIMI_SHELL_UNAVAILABLE_REASON }} For file operations, use the built-in tools (Read, Write, Edit, Glob, Grep), which work reliably across all platforms. +{% endif %} The operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory. ## Date and Time -The current date and time in ISO format is `{{ KIMI_NOW }}`. This is only a reference for you when searching the web, or checking file modification time, etc. If you need the exact time, use Bash tool with proper command. +The current date and time in ISO format is `{{ KIMI_NOW }}`. This is only a reference for you when searching the web, or checking file modification time, etc.{% if KIMI_SHELL_AVAILABLE == "true" %} If you need the exact time, use Bash tool with proper command.{% endif %} ## Working Directory diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index 001f7d19..b3a7511a 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -154,6 +154,8 @@ function buildTemplateVars( ...promptVars, KIMI_OS: context.osEnv.osKind, KIMI_SHELL: `${context.osEnv.shellName} (\`${context.osEnv.shellPath}\`)`, + KIMI_SHELL_AVAILABLE: context.osEnv.shellAvailable === false ? 'false' : 'true', + KIMI_SHELL_UNAVAILABLE_REASON: context.osEnv.shellUnavailableReason ?? '', KIMI_NOW: now, KIMI_WORK_DIR: context.cwd, KIMI_WORK_DIR_LS: context.cwdListing ?? '', diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 814dd70d..fdef170b 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -19,6 +19,7 @@ import type { GoalToolResult, } from '#/session/goal'; import type { ContentPart } from '@moonshot-ai/kosong'; +import type { Environment } from '@moonshot-ai/kaos'; import type { PluginInfo, PluginSummary, ReloadSummary } from '#/plugin'; import type { UsageStatus } from './events'; @@ -350,6 +351,7 @@ type SessionAPIWithId = WithSessionId; export interface CoreAPI extends SessionAPIWithId { getCoreInfo: (payload: EmptyPayload) => CoreInfo; + getEnvironment: (payload: EmptyPayload) => Environment; getExperimentalFlags: (payload: EmptyPayload) => ExperimentalFlagMap; 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 a6ede1ac..eea8a5ab 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -94,7 +94,7 @@ import type { import type { ResumedAgentState, ResumeSessionResult } from './resumed'; import type { SDKRPC } from './sdk-api'; import { proxyWithExtraPayload } from './types'; -import { KaosShellNotFoundError, LocalKaos, type Kaos } from '@moonshot-ai/kaos'; +import { LocalKaos, type Environment, type Kaos } from '@moonshot-ai/kaos'; import type { ToolServices } from '../tools/support/services'; const KIMI_CODE_PROVIDER_NAME = 'managed:kimi-code'; @@ -146,12 +146,10 @@ 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; - }); + // Shell (Git Bash on Windows) availability is now metadata on the + // resolved environment, not a hard failure: when it is missing the CLI + // still starts and the Bash tool is simply omitted (see ToolManager). + this.kaos = LocalKaos.create(); this.runtime = options.runtime; this.kimiRequestHeaders = options.kimiRequestHeaders; this.resolveOAuthTokenProvider = options.resolveOAuthTokenProvider; @@ -258,6 +256,15 @@ export class KimiCore implements PromisableMethods { return { version: getCoreVersion() }; } + /** + * Resolved OS / shell environment for this process. Surfaced so the UI + * layer can run a unified system-dependency check (shell availability, + * etc.) without re-probing — single source of truth is the core's kaos. + */ + async getEnvironment(): Promise { + return (await this.kaos).osEnv; + } + getExperimentalFlags(): ExperimentalFlagMap { const defs: readonly FlagDefinitionInput[] = FLAG_DEFINITIONS; return Object.fromEntries(defs.map((def) => [def.id, flags.enabled(def.id as FlagId)])); diff --git a/packages/agent-core/test/agent/config.test.ts b/packages/agent-core/test/agent/config.test.ts index d72937f7..76e989e8 100644 --- a/packages/agent-core/test/agent/config.test.ts +++ b/packages/agent-core/test/agent/config.test.ts @@ -2,6 +2,7 @@ import type { ModelCapability, ProviderConfig, ToolCall } from '@moonshot-ai/kos import { describe, expect, it } from 'vitest'; import type { ResolvedAgentProfile } from '../../src/profile'; +import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { createCommandKaos, testAgent } from './harness/agent'; import { DEFAULT_TEST_SYSTEM_PROMPT } from './harness/snapshots'; @@ -94,6 +95,29 @@ describe('Agent config', () => { await ctx.expectResumeMatches(); }); + it('omits Bash when the runtime shell is unavailable', async () => { + const ctx = testAgent({ + kaos: createFakeKaos({ + osEnv: { + osKind: 'Windows', + osArch: 'x64', + osVersion: '10.0.22631.0', + shellName: 'bash', + shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe', + shellAvailable: false, + shellUnavailableReason: 'Git Bash was not found.', + }, + }), + }); + ctx.configure(); + + const tools = await ctx.rpc.getTools({}); + + expect(toolNames(tools)).not.toContain('Bash'); + expect(toolNames(tools)).toEqual(expect.arrayContaining(['Read', 'Write', 'Edit', 'Grep', 'Glob'])); + await ctx.expectResumeMatches(); + }); + it('keeps turn-start config for later steps and applies updates to the next turn', async () => { const bashCall: ToolCall = { type: 'function', diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index 7c0db3ec..b1bff93a 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -732,7 +732,7 @@ export class AgentTestContext { async expectResumeMatches(): Promise { const resumed = testAgent({ - kaos: createResumeNoSideEffectKaos(this.agent.config.cwd), + kaos: createResumeNoSideEffectKaos(this.agent.config.cwd, this.agent.kaos.osEnv), runtime: { urlFetcher: this.agent.toolServices?.urlFetcher, webSearcher: this.agent.toolServices?.webSearcher, @@ -955,7 +955,10 @@ const failOnResumeGenerate: GenerateFn = async () => { throw new Error('Resume replay unexpectedly called the LLM'); }; -function createResumeNoSideEffectKaos(initialCwd: string): Kaos { +function createResumeNoSideEffectKaos( + initialCwd: string, + osEnv: Environment = TEST_OS_ENV, +): Kaos { const fail = (method: string): never => { throw new Error(`Resume replay unexpectedly called kaos.${method}`); }; @@ -966,12 +969,12 @@ function createResumeNoSideEffectKaos(initialCwd: string): Kaos { let cwd = initialCwd; return { name: 'resume-no-side-effects', - osEnv: TEST_OS_ENV, + osEnv, pathClass: () => 'posix', normpath: (p: string) => p, gethome: () => '/home/test', getcwd: () => cwd, - withCwd: (next: string) => createResumeNoSideEffectKaos(next), + withCwd: (next: string) => createResumeNoSideEffectKaos(next, osEnv), chdir: async (next: string) => { cwd = next; }, diff --git a/packages/agent-core/test/profile/agent-profile-loader.test.ts b/packages/agent-core/test/profile/agent-profile-loader.test.ts index fb4e7283..19f52f96 100644 --- a/packages/agent-core/test/profile/agent-profile-loader.test.ts +++ b/packages/agent-core/test/profile/agent-profile-loader.test.ts @@ -220,6 +220,29 @@ describe('default agent profiles', () => { expect(second).toContain('/workspace/two'); expect(second).not.toContain('/workspace/one'); }); + + it('renders unavailable shell guidance without Windows Git Bash command advice', () => { + const prompt = DEFAULT_AGENT_PROFILES['agent']?.systemPrompt({ + ...promptContext, + osEnv: { + osKind: 'Windows', + osArch: 'x64', + osVersion: '10.0.22631.0', + shellName: 'bash', + shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe', + shellAvailable: false, + shellUnavailableReason: 'Git Bash was not found.', + }, + }); + + expect(prompt).toContain('The Bash tool is unavailable: Git Bash was not found.'); + expect(prompt).not.toContain('The Bash tool runs through Git Bash, so use Unix shell syntax'); + // The scattered Bash-only guidance must drop out too, so the model is not + // told to use a tool it cannot call (see #291 review). + expect(prompt).not.toContain('Use `Bash` to run and test your code'); + expect(prompt).not.toContain('use Bash tool with proper command'); + expect(prompt).toContain('cannot run or test code through a shell'); + }); }); async function write(fileName: string, content: string): Promise { diff --git a/packages/kaos/src/environment.ts b/packages/kaos/src/environment.ts index 84cb3998..f5fa16bc 100644 --- a/packages/kaos/src/environment.ts +++ b/packages/kaos/src/environment.ts @@ -6,18 +6,16 @@ * identically on any host OS. `detectEnvironmentFromNode()` bundles the * Node defaults for production callers. * - * On Windows the probe expects Git Bash (the canonical POSIX shell that - * ships with Git for Windows). If it cannot be located the function - * throws `KaosShellNotFoundError`; the SDK layer can wrap that into a - * user-facing install hint. Set `KIMI_SHELL_PATH` to override. + * On Windows the probe prefers Git Bash (the canonical POSIX shell that + * ships with Git for Windows). If it cannot be located the function still + * returns the OS metadata with `shellAvailable=false` so the CLI can start + * and register the non-shell tools. Set `KIMI_SHELL_PATH` to override. */ import { constants as fsConstants } from 'node:fs'; import { access } from 'node:fs/promises'; import * as nodeOs from 'node:os'; -import { KaosShellNotFoundError } from './errors'; - // `OsKind` carries 'macOS' / 'Linux' / 'Windows' for known platforms and // falls back to the raw `process.platform` string for unknown ones (e.g. // 'freebsd'). Typed as `string` so the union isn't inhabited-by-string. @@ -30,6 +28,8 @@ export interface Environment { readonly osVersion: string; readonly shellName: ShellName; readonly shellPath: string; + readonly shellAvailable?: boolean; + readonly shellUnavailableReason?: string; } export interface EnvironmentDeps { @@ -62,8 +62,16 @@ export async function detectEnvironment(deps: EnvironmentDeps): Promise { +interface WindowsShellProbe { + readonly path: string; + readonly available: boolean; + readonly unavailableReason?: string; +} + +async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { const checked: string[] = []; const override = deps.env['KIMI_SHELL_PATH']?.trim(); if (override !== undefined && override.length > 0) { checked.push(override); if (await deps.isFile(override)) { - return override; + return { path: override, available: true }; } } @@ -98,7 +112,7 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { for (const path of inferred) { checked.push(path); if (await deps.isFile(path)) { - return path; + return { path, available: true }; } } } @@ -118,13 +132,16 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { for (const candidate of candidates) { checked.push(candidate); if (await deps.isFile(candidate)) { - return candidate; + return { path: candidate, available: true }; } } - throw new KaosShellNotFoundError( - `Git Bash was not found on this Windows host. Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe. Checked: ${checked.join(', ')}.`, - ); + const fallbackPath = override !== undefined && override.length > 0 ? override : candidates[0]!; + return { + path: fallbackPath, + available: false, + unavailableReason: `Git Bash was not found on this Windows host. Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe. Checked: ${checked.join(', ')}.`, + }; } // Most Git for Windows installs put `git.exe` in `\cmd\git.exe`, diff --git a/packages/kaos/src/errors.ts b/packages/kaos/src/errors.ts index 0283c9b0..03fbb15f 100644 --- a/packages/kaos/src/errors.ts +++ b/packages/kaos/src/errors.ts @@ -27,15 +27,3 @@ export class KaosFileExistsError extends KaosError { this.name = 'KaosFileExistsError'; } } - -/** - * Thrown by `detectEnvironment` on Windows when no Git Bash install can be - * located. Carries the list of paths that were probed so callers can include - * them in install hints. - */ -export class KaosShellNotFoundError extends KaosError { - constructor(message: string) { - super(message); - this.name = 'KaosShellNotFoundError'; - } -} diff --git a/packages/kaos/src/index.ts b/packages/kaos/src/index.ts index f69286cf..be92037a 100644 --- a/packages/kaos/src/index.ts +++ b/packages/kaos/src/index.ts @@ -8,12 +8,7 @@ export type { ShellName, } from './environment'; export { detectEnvironment, detectEnvironmentFromNode } from './environment'; -export { - KaosError, - KaosValueError, - KaosFileExistsError, - KaosShellNotFoundError, -} from './errors'; +export { KaosError, KaosValueError, KaosFileExistsError } from './errors'; export { LocalKaos } from './local'; export { chdir, diff --git a/packages/kaos/test/environment.test.ts b/packages/kaos/test/environment.test.ts index 2f020ee5..cfbf2ad8 100644 --- a/packages/kaos/test/environment.test.ts +++ b/packages/kaos/test/environment.test.ts @@ -7,8 +7,8 @@ * - POSIX path probing prefers /bin/bash, falls back to /usr/bin/bash, * /usr/local/bin/bash, then /bin/sh (with shellName 'sh'). * - Windows resolves Git Bash via `KIMI_SHELL_PATH`, `git.exe` on PATH, - * or well-known install locations; throws `KaosShellNotFoundError` - * if none are present. + * or well-known install locations; if none are present, it returns an + * unavailable shell marker so the CLI can still start. * - `osArch` / `osVersion` are populated from the Node OS APIs. * * All tests expect `detectEnvironment()` to be a pure function of @@ -24,7 +24,6 @@ import { type OsKind, type ShellName, } from '#/environment'; -import { KaosShellNotFoundError } from '#/errors'; interface StubOpts { readonly platform: NodeJS.Platform; @@ -200,7 +199,7 @@ describe('detectEnvironment', () => { expect(env.shellPath).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Git\\bin\\bash.exe'); }); -it('falls back to usr/bin under LOCALAPPDATA when bin/bash.exe is missing', async () => { + it('falls back to usr/bin under LOCALAPPDATA when bin/bash.exe is missing', async () => { const env = await detectEnvironment( stubDeps({ platform: 'win32', @@ -211,38 +210,33 @@ it('falls back to usr/bin under LOCALAPPDATA when bin/bash.exe is missing', asyn expect(env.shellPath).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Git\\usr\\bin\\bash.exe'); }); - it('throws KaosShellNotFoundError when no Git Bash candidate is found', async () => { - const error = await detectEnvironment( + it('returns an unavailable shell marker when no Windows Git Bash candidate is found', async () => { + const env = await detectEnvironment( stubDeps({ platform: 'win32', env: { LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local' }, existingPaths: [], }), - ).then( - () => { - throw new Error('expected throw'); - }, - (error: unknown) => error, ); - expect(error).toBeInstanceOf(KaosShellNotFoundError); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); + expect(env.shellAvailable).toBe(false); + expect(env.shellUnavailableReason).toContain('Git Bash was not found'); }); - it('includes attempted paths in the thrown error message', async () => { - const error = await detectEnvironment( + it('includes attempted paths in the unavailable shell reason', async () => { + const env = await detectEnvironment( stubDeps({ platform: 'win32', env: { KIMI_SHELL_PATH: 'D:\\custom\\bash.exe' }, existingPaths: [], }), - ).then( - () => { - throw new Error('expected throw'); - }, - (error: unknown) => error as KaosShellNotFoundError, ); - expect(error.message).toContain('D:\\custom\\bash.exe'); - expect(error.message).toContain('C:\\Program Files\\Git\\bin\\bash.exe'); - expect(error.message).toContain('C:\\Program Files\\Git\\usr\\bin\\bash.exe'); + expect(env.shellPath).toBe('D:\\custom\\bash.exe'); + expect(env.shellAvailable).toBe(false); + expect(env.shellUnavailableReason).toContain('D:\\custom\\bash.exe'); + expect(env.shellUnavailableReason).toContain('C:\\Program Files\\Git\\bin\\bash.exe'); + expect(env.shellUnavailableReason).toContain('C:\\Program Files\\Git\\usr\\bin\\bash.exe'); }); // ── arch / version passthrough ───────────────────────────────────── diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 28946514..2e86973e 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -50,6 +50,9 @@ export type { LogContext, LogLevel, LogPayload, Logger } from '@moonshot-ai/agen // "Goal complete · turns · tokens · time" text (live render + persisted message). export { buildGoalCompletionMessage } from '@moonshot-ai/agent-core'; +// OS / shell environment — resolved value comes from `KimiHarness.getEnvironment()`. +export type { Environment } from '@moonshot-ai/agent-core'; + // Experimental feature flags — types only. Resolved values come from // `KimiHarness.getExperimentalFlags()` over RPC, not from a re-exported runtime value. export type { diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 769cd3a3..e9cd8880 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -8,6 +8,7 @@ import { resolveKimiHome, resolveLoggingConfig, withTelemetryContext, + type Environment, type ExperimentalFlagMap, type TelemetryClient, type TelemetryContextPatch, @@ -197,6 +198,15 @@ export class KimiHarness { return this.rpc.getExperimentalFlags(); } + /** + * Resolved OS / shell environment for this process. Lets the UI run a + * unified system-dependency check (e.g. shell availability) against the + * core's single source of truth instead of re-probing. + */ + async getEnvironment(): Promise { + return this.rpc.getEnvironment(); + } + async ensureConfigFile(): Promise { await ensureConfigFile(this.configPath); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index ed57965b..d1ab1c8c 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -8,6 +8,7 @@ import { type ApprovalRequest, type ApprovalResponse, type CoreAPI, + type Environment, type Event, type ExperimentalFlagMap, type OAuthTokenProviderResolver, @@ -347,6 +348,12 @@ export class SDKRpcClient { }); } + /** Resolved OS / shell environment for this process (no session required). */ + async getEnvironment(): Promise { + const rpc = await this.getRpc(); + return rpc.getEnvironment({}); + } + async getStatus(input: SessionIdRpcInput): Promise { const rpc = await this.getRpc(); const agentId = this.interactiveAgentId;