Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/unified-system-dependency-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kaos": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
Comment thread
fancyboi999 marked this conversation as resolved.
---

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.
129 changes: 129 additions & 0 deletions apps/kimi-code/src/cli/system-deps/check.ts
Original file line number Diff line number Diff line change
@@ -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),
};
});
}
87 changes: 87 additions & 0 deletions apps/kimi-code/src/cli/system-deps/registry.ts
Original file line number Diff line number Diff line change
@@ -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;
}
43 changes: 43 additions & 0 deletions apps/kimi-code/src/cli/system-deps/report.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -105,6 +107,9 @@ export interface SlashCommandHost {
restoreEditor(): void;
restoreInputText(text: string): void;

// System dependencies (ripgrep / fd / shell) — unified status for `/status`.
collectSystemDependencyStatuses(): Promise<DependencyStatus[]>;

// Session
requireSession(): Session;
switchToSession(session: Session, message: string): Promise<void>;
Expand Down
13 changes: 12 additions & 1 deletion apps/kimi-code/src/tui/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -103,9 +104,10 @@ export async function showUsage(host: SlashCommandHost): Promise<void> {
}

export async function showStatusReport(host: SlashCommandHost): Promise<void> {
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({
Expand All @@ -127,6 +129,15 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> {
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();
Expand Down
36 changes: 36 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -404,13 +411,42 @@ 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);
this.state.ui.setFocus(this.state.editor);
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<DependencyStatus[]> {
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<void> {
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);
Expand Down
Loading