Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/detect-scoop-git-bash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kaos": patch
"@moonshot-ai/kimi-code": patch
---

Detect Git Bash installed through Scoop and other Git shims on Windows.
4 changes: 2 additions & 2 deletions docs/en/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,13 @@ The CLI also reads several standard system variables to detect the runtime envir

- `HOME`: used to resolve the default data path
- `VISUAL`, `EDITOR`: external editor command (`VISUAL` takes precedence)
- `PATH`: used to locate dependencies such as `rg` and `git`
- `PATH`: used to locate dependencies such as `rg` and `git`; on Windows, Git Bash detection checks each `git.exe` found on `PATH`, including package-manager shims such as Scoop
- `NO_COLOR`, `FORCE_COLOR`: control color output (following the [no-color.org](https://no-color.org) convention)
- `CI`: when non-empty and not `"0"`, disables theme detection and falls back to the dark theme
- `TERM_PROGRAM`, `TERM`, `TMUX`: detect terminal features and notification support
- `DISPLAY`, `WAYLAND_DISPLAY`, `XDG_SESSION_TYPE`: detect Linux graphical sessions (for clipboard and image features)
- `WSL_DISTRO_NAME`, `WSLENV`: detect WSL for the clipboard PowerShell bridge
- `LOCALAPPDATA`: used on Windows when probing for the Git Bash installation path
- `LOCALAPPDATA`: used on Windows as a fallback when probing for the Git Bash installation path

## HTTP proxy

Expand Down
4 changes: 2 additions & 2 deletions docs/zh/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,13 @@ CLI 还会读取一些标准系统变量来检测运行环境,不会修改它

- `HOME`:解析默认数据路径
- `VISUAL`、`EDITOR`:外部编辑器命令(`VISUAL` 优先)
- `PATH`:定位 `rg`、`git` 等依赖
- `PATH`:定位 `rg`、`git` 等依赖;在 Windows 上,Git Bash 探测会检查 `PATH` 中找到的每个 `git.exe`,包括 Scoop 等包管理器提供的 shim
- `NO_COLOR`、`FORCE_COLOR`:控制颜色输出(遵循 [no-color.org](https://no-color.org) 约定)
- `CI`:非空且非 `"0"` 时关闭主题检测,回退深色主题
- `TERM_PROGRAM`、`TERM`、`TMUX`:检测终端特性和通知支持
- `DISPLAY`、`WAYLAND_DISPLAY`、`XDG_SESSION_TYPE`:检测 Linux 图形会话(用于剪贴板和图片功能)
- `WSL_DISTRO_NAME`、`WSLENV`:检测 WSL,用于剪贴板 PowerShell 桥接
- `LOCALAPPDATA`:Windows 上探测 Git Bash 安装路径
- `LOCALAPPDATA`:Windows 上探测 Git Bash 安装路径时作为 fallback 使用

## HTTP 代理

Expand Down
165 changes: 135 additions & 30 deletions packages/kaos/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@
* Environment — cross-platform probe of OS / shell.
*
* Detection is a pure function of injected probes (`platform` / `arch` /
* `release` / `env` / `isFile` / `findExecutable`) so the same suite runs
* identically on any host OS. `detectEnvironmentFromNode()` bundles the
* Node defaults for production callers.
* `release` / `env` / `isFile` / `execFileText`) so the same suite runs
* 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.
*/

import { execFile as nodeExecFile } from 'node:child_process';
import { constants as fsConstants } from 'node:fs';
import { access } from 'node:fs/promises';
import * as nodeOs from 'node:os';
import * as nodePath from 'node:path';

import { KaosShellNotFoundError } from './errors';

Expand All @@ -40,9 +42,15 @@ export interface EnvironmentDeps {
readonly release: string;
readonly env: Record<string, string | undefined>;
readonly isFile: (path: string) => Promise<boolean>;
readonly findExecutable: (name: string) => Promise<string | undefined>;
readonly execFileText: (
file: string,
args: readonly string[],
timeoutMs: number,
) => Promise<string | undefined>;
}

const GIT_EXEC_PATH_TIMEOUT_MS = 5_000;

function resolveOsKind(platform: string): OsKind {
switch (platform) {
case 'darwin':
Expand Down Expand Up @@ -91,17 +99,34 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise<string> {
}
}

const gitExe = await deps.findExecutable('git.exe');
if (gitExe !== undefined) {
const inferred = inferGitBashFromGitExe(gitExe);
const gitExecutables = await findExecutablesOnPath(
'git.exe',
deps.env['PATH'],
deps.platform,
deps.isFile,
);

for (const gitExe of gitExecutables) {
const inferred = gitBashCandidatesFromGitExe(gitExe);
if (inferred !== undefined) {
for (const path of inferred) {
checked.push(path);
if (await deps.isFile(path)) {
return path;
for (const candidate of inferred) {
checked.push(candidate);
if (await deps.isFile(candidate)) {
return candidate;
}
}
}

const gitExecPath = await readGitExecPath(deps, gitExe);
if (gitExecPath === undefined) {
continue;
}
for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) {
checked.push(candidate);
if (await deps.isFile(candidate)) {
return candidate;
}
}
}

const candidates: string[] = [
Expand All @@ -127,24 +152,81 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise<string> {
);
}

// Most Git for Windows installs put `git.exe` in `<root>\cmd\git.exe`,
// with bash at `<root>\bin\bash.exe` (a wrapper) or `<root>\usr\bin\bash.exe`
// (the real MSYS2 shell). Walk back to the parent of `cmd` / `bin` and
// return both candidates so the caller can try them in preference order.
function inferGitBashFromGitExe(gitExe: string): string[] | undefined {
const sep = gitExe.includes('\\') ? '\\' : '/';
const parts = gitExe.split(sep);
for (let i = parts.length - 2; i >= 0; i -= 1) {
const segment = parts[i];
if (segment === 'cmd' || segment === 'bin') {
const root = parts.slice(0, i).join(sep);
const prefix = root.length === 0 ? '' : `${root}${sep}`;
return [`${prefix}bin${sep}bash.exe`, `${prefix}usr${sep}bin${sep}bash.exe`];
async function readGitExecPath(
deps: EnvironmentDeps,
gitExe: string,
): Promise<string | undefined> {
if (deps.platform === 'win32' && !isAbsoluteWindowsPath(gitExe)) return undefined;

const stdout = await deps.execFileText(gitExe, ['--exec-path'], GIT_EXEC_PATH_TIMEOUT_MS);
if (stdout === undefined) return undefined;

for (const line of stdout.split(/\r?\n/)) {
const execPath = line.trim();
if (execPath.length > 0) {
return execPath;
}
}
return undefined;
}

// Most Git for Windows installs put `git.exe` in `<root>\cmd\git.exe`,
// with bash at `<root>\bin\bash.exe`. Portable installs sometimes put
// both in `<root>\bin\`. Only infer from those anchored layouts; package
// manager shims live elsewhere and must resolve through `git --exec-path`.
function gitBashCandidatesFromGitExe(gitExe: string): readonly string[] | undefined {
const normalizedGitExe = nodePath.win32.normalize(normalizeWindowsPath(gitExe));
const gitDir = nodePath.win32.dirname(normalizedGitExe);
const gitDirName = nodePath.win32.basename(gitDir).toLowerCase();
if (gitDirName !== 'cmd' && gitDirName !== 'bin') {
return undefined;
}
return gitBashCandidatesFromGitRoot(nodePath.win32.dirname(gitDir));
}

function gitBashCandidatesFromGitExecPath(execPath: string): readonly string[] {
const normalized = nodePath.win32.normalize(normalizeWindowsPath(execPath));
const parts = normalized.split('\\');
for (let i = parts.length - 1; i >= 0; i -= 1) {
const segment = parts[i]?.toLowerCase();
if (segment === 'mingw32' || segment === 'mingw64') {
const root = parts.slice(0, i).join('\\');
if (root.length > 0) {
return gitBashCandidatesFromGitRoot(root);
}
}
}

return gitBashCandidatesFromGitRoot(nodePath.win32.join(normalized, '..', '..'));
}

function gitBashCandidatesFromGitRoot(root: string): readonly string[] {
return [
nodePath.win32.normalize(nodePath.win32.join(root, 'bin', 'bash.exe')),
nodePath.win32.normalize(nodePath.win32.join(root, 'usr', 'bin', 'bash.exe')),
];
}

function normalizeWindowsPath(path: string): string {
return path.replaceAll('/', '\\');
}

function isAbsoluteWindowsPath(path: string): boolean {
return nodePath.win32.isAbsolute(normalizeWindowsPath(path));
}

function dedupeWindowsPaths(paths: readonly string[]): readonly string[] {
const deduped: string[] = [];
const seen = new Set<string>();
for (const path of paths) {
const key = normalizeWindowsPath(path).toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
deduped.push(path);
}
return deduped;
}

/**
* Production convenience — derive the deps bag from Node's ambient surface.
*
Expand Down Expand Up @@ -175,27 +257,50 @@ export function detectEnvironmentFromNode(): Promise<Environment> {
release: nodeOs.release(),
env,
isFile,
findExecutable: (name: string) => findExecutableOnPath(name, env['PATH'], platform, isFile),
execFileText,
});
return detectedEnvironment;
}

async function findExecutableOnPath(
async function findExecutablesOnPath(
name: string,
pathEnv: string | undefined,
platform: string,
isFile: (p: string) => Promise<boolean>,
): Promise<string | undefined> {
if (pathEnv === undefined || pathEnv.length === 0) return undefined;
): Promise<readonly string[]> {
if (pathEnv === undefined || pathEnv.length === 0) return [];
const listSep = platform === 'win32' ? ';' : ':';
const dirSep = platform === 'win32' ? '\\' : '/';
const paths: string[] = [];
for (const rawDir of pathEnv.split(listSep)) {
const dir = rawDir.trim();
if (dir.length === 0) continue;
if (platform === 'win32' && !isAbsoluteWindowsPath(dir)) continue;
const candidate = dir.endsWith(dirSep) ? `${dir}${name}` : `${dir}${dirSep}${name}`;
if (await isFile(candidate)) {
return candidate;
paths.push(candidate);
}
}
return undefined;
return platform === 'win32' ? dedupeWindowsPaths(paths) : paths;
}

async function execFileText(
file: string,
args: readonly string[],
timeoutMs: number,
): Promise<string | undefined> {
return new Promise((resolve) => {
nodeExecFile(
file,
[...args],
{ encoding: 'utf8', timeout: timeoutMs, windowsHide: true },
(error, stdout) => {
if (error !== null) {
resolve(undefined);
return;
}
resolve(stdout);
},
);
});
}
Loading
Loading