从一个因缺少 ripgrep 而失败的搜索 Tool 出发,亲手实现检测、解析、下载、SHA256 校验、安装和复用闭环。
让一个找不到 rg 的搜索 Tool,自动下载、校验、安装并继续执行。
这是一篇独立拓展教程。我们会做出一个最小 Runtime / Binary Manager,也就是“程序自己的工具安装管理员”。
预计用时: 完整跟做约 60~90 分钟;只下载完成版跑通约 15 分钟,均不含网络等待。核心成果: 第一次运行自动准备 ripgrep 并完成搜索,第二次运行直接复用。成功标志: 终端最后能找到两行包含 MCP 的文字,而且第二次没有再次下载和解压。
本文 Demo 只实现 Windows 10/11 x64。它不会修改系统 PATH,不需要管理员权限,也不会分析任何商业 Agent 的内部源码。你只是在一个自己的小项目里,把“缺少命令”这件事从报错变成闭环。
如果暂时只想理解思路,可以先看每节开头和结果;真正动手时,再回来复制代码。别被文件数量吓到,它们只是把一份工作拆给了几位专职小同事。
只想先确认闭环是否值得做,可以下载 Runtime Manager Demo v2,按照压缩包里的 README 跑一遍;确认有兴趣后,再回到正文逐个文件搭建。
1. 先看终点:我们到底要做什么?
最后的使用体验很简单:搜索 Tool 声明自己需要 ripgrep >=14,Runtime Manager 负责把这个要求变成一个满足版本要求的 rg.exe 路径。
严格来说,这个 Demo 管理的是一个可执行文件,所以叫 Binary Manager 更准确;Runtime Manager 是更宽的说法,还可以管理 Python、Node.js 等完整运行环境。本文沿用 “Runtime / Binary Manager”,但真正动手实现的是最小 Binary Manager。
Tool 被调用
↓
系统里有满足版本要求的 rg?有就直接用
↓ 没有,或版本太旧
项目的 .runtime 里有?有就复用
↓ 没有
解析版本 → 下载 → SHA256 校验 → 安装
↓
把 rg.exe 路径交回原来的 Tool
↓
继续搜索,不要求用户重来一次这一篇最重要的不是 ripgrep,而是职责分开:
- Tool 负责说“我需要什么,以及拿到以后怎么用”;
- Runtime Manager 负责“检测、准备并返回可执行文件”;
- Manifest 负责记录“哪个平台该下载哪个文件”。
先记住这三句,后面的代码就不容易迷路。
2. 准备环境:这次只需要 Node.js
主线需要:
- Windows 10/11 x64:本篇安装器使用 PowerShell 的
Expand-Archive; - Node.js 22 或更高版本:负责运行 TypeScript、下载文件和启动子进程;
- PowerShell:Windows 自带的终端;
- 任意代码编辑器:VS Code、CodeBuddy 或其他编辑器都可以,本文不依赖某一款 AI IDE。
不需要提前安装 ripgrep,不需要 Git,也不需要管理员权限。运行前请确认浏览器能打开 ripgrep 14.1.1 官方 Release;公司网络无法访问 GitHub 时,下载步骤会失败,请先遵守公司的网络与软件安装规定。
Node.js 请从官方下载页获取,并选择 LTS 版本。已经能执行下面命令时,不用重装:
node -v
npm -v
node -e "console.log(process.platform, process.arch)"你应该看到类似结果:
v22.23.1
10.9.8
win32 x64版本号可以更新,但最后一行必须是 win32 x64。如果是别的平台,请先把本文当作原理教程,不要照抄安装部分。
3. 创建项目,先把空房间搭好
在 PowerShell 中执行:
mkdir my-runtime-manager
cd my-runtime-manager
npm init -y
npm install -D typescript@7.0.2 tsx@4.23.12 @types/node@26.2.0
mkdir src
mkdir src\runtime
mkdir demo再创建这些空文件:
New-Item -ItemType File -Force -Path .\src\index.ts
New-Item -ItemType File -Force -Path .\src\runtime\detector.ts
New-Item -ItemType File -Force -Path .\src\runtime\manifest.ts
New-Item -ItemType File -Force -Path .\src\runtime\downloader.ts
New-Item -ItemType File -Force -Path .\src\runtime\verifier.ts
New-Item -ItemType File -Force -Path .\src\runtime\installer.ts
New-Item -ItemType File -Force -Path .\src\runtime\manager.ts
New-Item -ItemType File -Force -Path .\demo\mcp-intro.md
New-Item -ItemType File -Force -Path .\runtime-manifest.json
New-Item -ItemType File -Force -Path .\tsconfig.json目录应该变成:
my-runtime-manager/
├─ demo/
│ └─ mcp-intro.md
├─ src/
│ ├─ runtime/
│ │ ├─ detector.ts
│ │ ├─ downloader.ts
│ │ ├─ installer.ts
│ │ ├─ manager.ts
│ │ ├─ manifest.ts
│ │ └─ verifier.ts
│ └─ index.ts
├─ package.json
├─ runtime-manifest.json
└─ tsconfig.json把下面配置写入 tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": ["node"],
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*.ts"]
}测试文件 demo/mcp-intro.md 写入:
# MCP 学习笔记
MCP 可以让 AI 应用发现并调用外部工具。
这个项目会实现一个 search_workspace Tool,用来搜索工作区里的文本。
后面我们还会搜索 registerTool 这个关键词。到这里的成功标志: 文件结构齐全,node_modules 已出现,安装命令没有以红色错误结束。
4. 先写一个注定失败的搜索 Tool
ripgrep 是一个速度很快的文本搜索命令,真正执行时使用 rg。先问问电脑有没有它:
rg --version如果 PowerShell 报“无法将 rg 识别为 cmdlet”,正好符合实验条件:
如果你的电脑已经能返回版本号,不要卸载。后面我们会用一个只影响当前终端的开关,仍然可以体验托管版本。
先把 src/index.ts 写成最原始的版本:
import { spawn } from "node:child_process";
function searchWorkspace(query: string): void {
console.log(`[Tool] 正在搜索:${query}`);
const child = spawn(
"rg",
[
"--line-number",
"--no-heading",
query,
"./demo",
],
{
stdio: "inherit",
},
);
child.on("error", (error) => {
console.error("[Tool] 无法启动 ripgrep");
console.error(error);
});
}
searchWorkspace("MCP");运行:
npx tsx src/index.ts系统没有 rg 时,会得到 spawn rg ENOENT:
代码知道要搜索,但真正的执行能力并不存在。这就是本篇的起点:有 Tool,不等于 Tool 运行得起来。
5. 第一次升级:让 Tool 声明“我需要什么”
最省事的写法,是直接把下载逻辑塞进 searchWorkspace()。但下一个 Tool 需要 Python,再下一个需要 FFmpeg 时,每个 Tool 都会长出一套安装器,场面很快从“自动化”滑向“自动添乱”。
更合适的做法,是让 Tool 只声明要求:
type RuntimeRequirement = {
type: string;
version: string;
};
const searchWorkspaceTool = {
name: "search_workspace",
runtime: {
type: "ripgrep",
version: ">=14",
} satisfies RuntimeRequirement,
};ripgrep >=14 只是“需求”,还不是下载地址。接下来,我们会依次回答三件事:本机有没有、应该下载哪个、下载后怎么放心执行。
6. Detector:先找系统,再找自己的托管目录
把下面代码写入 src/runtime/detector.ts:
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
export function detectSystemBinary(command: string): string | null {
const locator = process.platform === "win32"
? "where.exe"
: "which";
const result = spawnSync(
locator,
[command],
{
encoding: "utf8",
windowsHide: true,
},
);
if (result.status !== 0) {
return null;
}
const firstPath = result.stdout
.trim()
.split(/\r?\n/)[0];
return firstPath || null;
}
export function readRipgrepVersion(
binaryPath: string,
): string | null {
const result = spawnSync(
binaryPath,
["--version"],
{
encoding: "utf8",
windowsHide: true,
},
);
if (result.status !== 0) {
return null;
}
const match = result.stdout.match(
/^ripgrep\s+(\d+\.\d+\.\d+)/m,
);
return match?.[1] ?? null;
}
export function detectManagedBinary(
version: string,
): string | null {
const executable =
process.platform === "win32"
? "rg.exe"
: "rg";
const binaryPath = resolve(
".runtime",
"ripgrep",
"versions",
version,
executable,
);
if (!existsSync(binaryPath)) {
return null;
}
return binaryPath;
}这里有两种 Binary:
- System Binary:用户自己已经安装,能被系统 PATH 找到;
- Managed Binary:只放在当前项目的
.runtime/,由我们的代码管理。
找到 System Binary 后,readRipgrepVersion() 还会执行一次 rg --version。只有版本满足 Tool 的要求,Manager 才会使用它;否则继续准备托管版本。
检测顺序先尊重符合要求的系统版本,再看自己是否准备过。两处都没有时,日志会清楚停在这里:
这一步还没有解决缺失问题,但已经把“直到 spawn 才突然爆炸”变成“执行前知道少了哪一层”。
7. Manifest:把模糊要求变成具体下载文件
Tool 只说 >=14,下载器却必须知道版本、操作系统、CPU 架构、URL 和校验值。把这些确定事实放进 runtime-manifest.json:
{
"ripgrep": {
"14.1.1": {
"win32-x64": {
"asset": "ripgrep-14.1.1-x86_64-pc-windows-msvc.zip",
"url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-pc-windows-msvc.zip",
"sha256": "d0f534024c42afd6cb4d38907c25cd2b249b79bbe6cc1dbee8e3e37c2b6e25a1"
}
}
}
}这个 URL 指向 ripgrep 官方 GitHub Release。SHA256 已在 2026-08-21 的真实下载文件上复验通过。
再把解析逻辑写入 src/runtime/manifest.ts:
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
type Artifact = {
asset: string;
url: string;
sha256: string;
};
type RuntimeManifest = Record<
string,
Record<string, Record<string, Artifact>>
>;
export type ResolvedArtifact = Artifact & {
version: string;
platformKey: string;
};
export function satisfiesRequirement(
version: string,
requirement: string,
): boolean {
const match = requirement.match(/^>=(\d+)$/);
if (!match) {
throw new Error(
`Demo 暂时只支持形如 >=14 的版本要求:${requirement}`,
);
}
const requiredMajor = Number(match[1]);
const actualMajor = Number(
version.split(".")[0],
);
return actualMajor >= requiredMajor;
}
export function resolveArtifact(
runtimeType: string,
requirement: string,
): ResolvedArtifact {
const manifestPath = resolve(
"runtime-manifest.json",
);
const manifest = JSON.parse(
readFileSync(manifestPath, "utf8"),
) as RuntimeManifest;
const runtime = manifest[runtimeType];
if (!runtime) {
throw new Error(
`Manifest 中没有 Runtime:${runtimeType}`,
);
}
const versions = Object.keys(runtime)
.filter((version) =>
satisfiesRequirement(version, requirement),
)
.sort((a, b) =>
b.localeCompare(a, undefined, { numeric: true }),
);
const version = versions[0];
if (!version) {
throw new Error(
`没有版本满足要求:${runtimeType} ${requirement}`,
);
}
const platformKey =
`${process.platform}-${process.arch}`;
const artifact =
runtime[version][platformKey];
if (!artifact) {
throw new Error(
`当前平台没有可用 Artifact:${platformKey}`,
);
}
return {
version,
platformKey,
...artifact,
};
}这份 Demo 的版本判断故意很小,只支持 >=数字,例如 >=14。成熟实现应使用完整语义化版本库,但第一次实验先别把自己写成 npm 的远房表亲。
8. 下载与校验:能拿回来,还要确认拿对了
把下载代码写入 src/runtime/downloader.ts:
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import type { ResolvedArtifact } from "./manifest";
export async function downloadArtifact(
artifact: ResolvedArtifact,
): Promise<string> {
const downloadDir = resolve(
".runtime",
"downloads",
);
await mkdir(downloadDir, {
recursive: true,
});
const archivePath = resolve(
downloadDir,
artifact.asset,
);
console.log(
`[Runtime] 正在下载:${artifact.asset}`,
);
const response = await fetch(artifact.url);
if (!response.ok) {
throw new Error(
`下载失败:HTTP ${response.status} ${response.statusText}`,
);
}
const data = Buffer.from(
await response.arrayBuffer(),
);
await writeFile(
archivePath,
data,
);
console.log(
`[Runtime] 下载完成:${archivePath}`,
);
return archivePath;
}它只做四件事:建下载目录、请求 URL、检查 HTTP 状态、写入 ZIP。安全判断不应该偷偷混在下载函数里,所以再写一个独立校验器。
把下面代码写入 src/runtime/verifier.ts:
import { createReadStream } from "node:fs";
import { createHash } from "node:crypto";
export async function verifySha256(
filePath: string,
expectedSha256: string,
): Promise<void> {
const hash = createHash("sha256");
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(filePath);
stream.on("data", (chunk) => {
hash.update(chunk);
});
stream.on("error", reject);
stream.on("end", resolve);
});
const actualSha256 = hash.digest("hex");
console.log(
`[Runtime] 实际 SHA256:${actualSha256}`,
);
if (
actualSha256.toLowerCase() !==
expectedSha256.toLowerCase()
) {
throw new Error(
`SHA256 校验失败\n期望:${expectedSha256}\n实际:${actualSha256}`,
);
}
console.log("[Runtime] SHA256 校验通过");
}SHA256 在这里解决的是完整性:实际文件必须和 Manifest 登记的那一份一致。它不自动等于“来源绝对可信”;真正的生产系统还会保护 Manifest、限制来源,并尽可能验证发布者签名。
真实实验中,期望值和实际值一致:
到这里,ZIP 已经“下载成功 + 校验通过”,但它仍然不能直接当命令执行。下一步才是安装。
9. Installer:解压,再把 rg.exe 放进项目托管目录
把下面代码写入 src/runtime/installer.ts:
import { spawnSync } from "node:child_process";
import {
copyFile,
mkdir,
readdir,
rm,
} from "node:fs/promises";
import {
join,
resolve,
} from "node:path";
import type {
ResolvedArtifact,
} from "./manifest";
async function findFile(
directory: string,
fileName: string,
): Promise<string | null> {
const entries = await readdir(
directory,
{
withFileTypes: true,
},
);
for (const entry of entries) {
const fullPath = join(
directory,
entry.name,
);
if (
entry.isFile() &&
entry.name.toLowerCase() ===
fileName.toLowerCase()
) {
return fullPath;
}
if (entry.isDirectory()) {
const found = await findFile(
fullPath,
fileName,
);
if (found) {
return found;
}
}
}
return null;
}
function quotePowerShell(
value: string,
): string {
return `'${value.replace(/'/g, "''")}'`;
}
export async function installArtifact(
archivePath: string,
artifact: ResolvedArtifact,
): Promise<string> {
if (process.platform !== "win32") {
throw new Error(
"这个 Demo 的安装步骤目前只实现了 Windows",
);
}
const stagingDir = resolve(
".runtime",
"staging",
`ripgrep-${artifact.version}`,
);
const installDir = resolve(
".runtime",
"ripgrep",
"versions",
artifact.version,
);
console.log(
`[Runtime] 正在解压:${artifact.asset}`,
);
await rm(
stagingDir,
{
recursive: true,
force: true,
},
);
await mkdir(
stagingDir,
{
recursive: true,
},
);
const command =
`Expand-Archive ` +
`-LiteralPath ${quotePowerShell(archivePath)} ` +
`-DestinationPath ${quotePowerShell(stagingDir)} ` +
`-Force`;
const result = spawnSync(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-Command",
command,
],
{
stdio: "inherit",
windowsHide: true,
},
);
if (result.status !== 0) {
throw new Error(
"解压 ripgrep 失败",
);
}
console.log(
"[Runtime] 解压完成",
);
const extractedBinary =
await findFile(
stagingDir,
"rg.exe",
);
if (!extractedBinary) {
throw new Error(
"解压完成,但没有找到 rg.exe",
);
}
await mkdir(
installDir,
{
recursive: true,
},
);
const installedBinary = join(
installDir,
"rg.exe",
);
console.log(
`[Runtime] 正在安装到:${installedBinary}`,
);
await copyFile(
extractedBinary,
installedBinary,
);
await rm(
stagingDir,
{
recursive: true,
force: true,
},
);
console.log(
"[Runtime] 安装完成",
);
return installedBinary;
}它先解压到 .runtime/staging,找到 rg.exe 后,再复制到稳定位置:
.runtime/
├─ downloads/
│ └─ ripgrep-14.1.1-x86_64-pc-windows-msvc.zip
├─ ripgrep/
│ └─ versions/
│ └─ 14.1.1/
│ └─ rg.exe
└─ staging/安装完成后,托管的二进制文件能独立报告版本:
注意:我们没有把它写进系统 PATH。Runtime Manager 最后会直接返回绝对路径,由 Tool 使用这个路径启动进程。
10. Manager:把五个零件串成 ensureBinary()
前面有 Detector、Manifest、Downloader、Verifier 和 Installer。现在把它们排成真正的执行顺序。
将下面代码写入 src/runtime/manager.ts:
import {
detectManagedBinary,
detectSystemBinary,
readRipgrepVersion,
} from "./detector";
import {
resolveArtifact,
satisfiesRequirement,
} from "./manifest";
import {
downloadArtifact,
} from "./downloader";
import {
verifySha256,
} from "./verifier";
import {
installArtifact,
} from "./installer";
export type RuntimeRequirement = {
type: string;
version: string;
};
export type BinaryInfo = {
type: string;
path: string;
source: "system" | "managed";
version?: string;
};
function getCommandName(
runtimeType: string,
): string {
if (runtimeType === "ripgrep") {
return "rg";
}
throw new Error(
`暂不支持的 Runtime:${runtimeType}`,
);
}
export async function ensureBinary(
requirement: RuntimeRequirement,
): Promise<BinaryInfo> {
const command = getCommandName(
requirement.type,
);
console.log(
`[Runtime] 正在检测系统中的 ${requirement.type}...`,
);
const systemBinary =
process.env.RUNTIME_FORCE_MANAGED === "1"
? null
: detectSystemBinary(command);
if (systemBinary) {
const systemVersion =
readRipgrepVersion(systemBinary);
if (
systemVersion &&
satisfiesRequirement(
systemVersion,
requirement.version,
)
) {
console.log(
`[Runtime] 找到可用的系统 ${requirement.type} ${systemVersion}:${systemBinary}`,
);
return {
type: requirement.type,
path: systemBinary,
source: "system",
version: systemVersion,
};
}
console.log(
`[Runtime] 系统 ${requirement.type} 版本 ${systemVersion ?? "无法识别"} 不满足 ${requirement.version},改用托管版本`,
);
} else {
console.log(
`[Runtime] 未找到系统 ${requirement.type}`,
);
}
console.log(
"[Runtime] 正在解析 Runtime Artifact...",
);
const artifact = resolveArtifact(
requirement.type,
requirement.version,
);
console.log(
`[Runtime] 已选择:${artifact.version} / ${artifact.platformKey}`,
);
console.log(
`[Runtime] 正在检查托管的 ${requirement.type}...`,
);
const managedBinary =
detectManagedBinary(
artifact.version,
);
if (managedBinary) {
console.log(
`[Runtime] 找到托管 ${requirement.type}:${managedBinary}`,
);
console.log(
"[Runtime] 直接复用已有 Managed Binary",
);
return {
type: requirement.type,
path: managedBinary,
source: "managed",
version: artifact.version,
};
}
console.log(
`[Runtime] 未找到托管 ${requirement.type}`,
);
const archivePath =
await downloadArtifact(
artifact,
);
console.log(
"[Runtime] 正在验证 SHA256...",
);
await verifySha256(
archivePath,
artifact.sha256,
);
const installedBinary =
await installArtifact(
archivePath,
artifact,
);
console.log(
"[Runtime] Runtime 已准备完成",
);
return {
type: requirement.type,
path: installedBinary,
source: "managed",
version: artifact.version,
};
}Manager 现在不会把“找到命令”误当成“满足要求”:系统版本合格才直接使用,版本太旧或无法识别时会继续走托管分支。
RUNTIME_FORCE_MANAGED=1 只是教学开关。电脑已经有合格的 rg 时,它会让这一次实验跳过系统版本,从而真实走一遍下载与安装;它不会修改 Windows 环境变量的永久配置。
11. 把 Tool 改成最终版本
最后,将 src/index.ts 替换为:
import { spawn } from "node:child_process";
import {
ensureBinary,
type RuntimeRequirement,
} from "./runtime/manager";
const searchWorkspaceTool = {
name: "search_workspace",
runtime: {
type: "ripgrep",
version: ">=14",
} satisfies RuntimeRequirement,
};
async function searchWorkspace(
query: string,
): Promise<void> {
console.log(
`[Tool] ${searchWorkspaceTool.name}`,
);
console.log(
`[Tool] 运行时要求:${searchWorkspaceTool.runtime.type} ${searchWorkspaceTool.runtime.version}`,
);
const binary = await ensureBinary(
searchWorkspaceTool.runtime,
);
console.log(
`[Tool] Runtime 已就绪:${binary.path}`,
);
console.log(
`[Tool] 正在搜索:${query}`,
);
await new Promise<void>(
(resolve, reject) => {
const child = spawn(
binary.path,
[
"--line-number",
"--no-heading",
query,
"./demo",
],
{
stdio: "inherit",
},
);
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve();
return;
}
reject(
new Error(
`ripgrep 退出码:${code}`,
),
);
});
},
);
}
searchWorkspace("MCP").catch(
(error: unknown) => {
const message =
error instanceof Error
? error.message
: String(error);
console.error(
`[Tool] 当前无法执行:${message}`,
);
process.exitCode = 1;
},
);Tool 现在只剩两类责任:声明 ripgrep >=14,以及拿到 binary.path 后执行搜索。下载细节已经从业务代码里消失了。
12. 第一次完整运行:缺少就自动准备
先做 TypeScript 检查:
npx tsc --noEmit没有输出且回到提示符,就代表类型检查通过。
为了让已经安装 rg 的电脑也能复现托管分支,在当前 PowerShell 会话设置教学开关:
$env:RUNTIME_FORCE_MANAGED = "1"
npx tsx src/index.ts第一次运行会依次看到:
[Tool] search_workspace
[Tool] 运行时要求:ripgrep >=14
[Runtime] 未找到系统 ripgrep
[Runtime] 已选择:14.1.1 / win32-x64
[Runtime] 未找到托管 ripgrep
[Runtime] 正在下载:ripgrep-14.1.1-x86_64-pc-windows-msvc.zip
[Runtime] SHA256 校验通过
[Runtime] 解压完成
[Runtime] 安装完成
[Runtime] Runtime 已准备完成
[Tool] 正在搜索:MCP
./demo\mcp-intro.md:1:# MCP 学习笔记
./demo\mcp-intro.md:3:MCP 可以让 AI 应用发现并调用外部工具。核心成功标志: 最后出现两条搜索结果。安装不是一个需要用户重新开始的前置任务;同一次 Tool 调用会在准备完成后继续走到搜索。
如果卡在下载,先在浏览器打开 Manifest 中的 GitHub Release URL;如果报 SHA256 不一致,删除当前项目下刚下载的 ZIP,确认 Manifest 没有被改过,再重试。不要为了“先跑起来”直接删掉校验步骤。
如果复制时想核对差异,开头提供的 Demo v2 就是这套代码的完整成品。
13. 第二次运行:不重复安装,直接复用
不要改任何代码,再运行一次:
npx tsx src/index.ts这次 Runtime Manager 会找到 .runtime/ripgrep/versions/14.1.1/rg.exe,直接复用已有 Managed Binary:
两次运行的差别很清楚:
| 调用 | 当前状态 | 实际行为 | | --- | --- | --- | | 第一次 | System 没有 / Managed 没有 | Resolve → Download → Verify → Install → Execute | | 第二次 | System 没有 / Managed 已有 | Resolve → Reuse → Execute |
Runtime Manager 不是“每次都帮你重装”,而是“确保运行环境存在”。已经存在时,最正确的动作就是少折腾。
实验完成后清掉当前终端的教学开关:
Remove-Item Env:RUNTIME_FORCE_MANAGED以后不设置它,代码会优先使用系统中已经存在的 rg。
14. 为什么这比直接改 PATH 更舒服?
本篇没有把 rg.exe 安装到 Program Files,也没有永久修改系统 PATH。Tool 拿到的是一个具体文件路径,然后执行:
spawn(binary.path, args);这样做有几个直接好处:
- 不需要管理员权限;
- 不会影响其他项目正在使用的
rg; - 一个项目可以明确管理自己的版本;
- 删除当前项目的
.runtime/就能清理托管环境; - 失败更容易定位在检测、解析、下载、校验、安装或执行中的某一层。
代价也很真实:每个项目可能重复占用磁盘,Manifest 和缓存策略需要维护,多平台安装逻辑也要自己负责。它不是万能答案,只是在 Agent Tool 需要稳定、可控依赖时非常有用的一种边界。
15. 放回 Agent 世界:它不是 Hook,也不是 Tool 本身
Runtime Manager 经常发生在 Tool 执行之前,但“执行得早”不等于“它就是 Hook”。
- Tool:一个具体动作,例如搜索工作区;
- Skill:告诉 Agent 一类任务应该怎么做;
- MCP:让客户端用统一协议发现和调用外部能力;
- Hook:在开始、执行前、执行后等时机触发逻辑;
- Runtime / Binary Manager:确保 Tool 依赖的真实执行环境存在。
它们可以这样合作:
用户提出任务
↓
Agent 选择 Skill / Tool
↓
MCP 或 Plugin 暴露能力
↓
Runtime Manager.ensure()
↓
Node / Python / FFmpeg / ripgrep 等真正执行
↓
结果返回 Agent所以本篇不是在证明“某个 Agent 产品内部一定这样实现”,而是在用独立 Demo 回答一个工程问题:当 Tool 的依赖不存在时,我们可以怎样把失败变成可控的准备流程?
16. 这个最小 Demo 还缺什么?
现在的版本已经能演示主链,但离成熟系统还有距离:
- 只支持 Windows x64 和 ZIP;
- 只支持 ripgrep;
- 版本表达式只认
>=主版本号; - 没有下载超时、重试和镜像回退;
- 没有并发安装锁,两个进程可能同时准备同一版本;
- 没有原子安装、回滚、损坏自愈和定期清理;
- 只校验 SHA256,还没有发布者签名或更完整的供应链校验;
- Manifest 是本地文件,生产环境还要限制谁能修改它。
这不是“做得不够像生产”,而是刻意把第一版压到可以看懂。先跑通 Declare → Detect → Resolve → Provision → Execute,再逐个添加可靠性,学习成本会舒服很多。
17. 常见问题
我的电脑已经有 rg,为什么没有下载?
默认逻辑会优先使用满足 >=14 的 System Binary;低于 14 或无法识别版本时,会自动改用托管版本。想让合格的系统版本也走一次下载流程,请在当前 PowerShell 里设置 $env:RUNTIME_FORCE_MANAGED = "1",实验结束后用 Remove-Item Env:RUNTIME_FORCE_MANAGED 清掉。它不会永久修改系统 PATH。
为什么报“当前平台没有可用 Artifact”?
先运行 node -e "console.log(process.platform, process.arch)"。本文 Manifest 只有 win32-x64;macOS、Linux 或 arm64 需要补对应下载文件和安装逻辑。
为什么报 SHA256 校验失败?
当前 ZIP 内容与 Manifest 记录不一致。先停止安装,不要继续执行;删除当前项目 .runtime/downloads 里的对应 ZIP,核对官方 Release URL 和 SHA256 后再试。
为什么 runtime-manifest.json 明明存在,却提示找不到?
请确认 PowerShell 当前路径就是 my-runtime-manager 项目根目录。Demo 使用相对路径读取 Manifest,从别的目录启动会找错位置。
可以把 ripgrep 换成 Python 或 FFmpeg 吗?
可以,但不只是换 URL。你还要定义版本解析、各平台 Artifact、校验值、解压或安装方式、可执行文件位置和健康检查。框架相同,安装细节会因 Runtime 而变。
18. 做完以后,我们真正学会了什么?
这次完成了三件事:
- 让 Tool 明确声明自己的运行时要求;
- 把下载、SHA256 校验和安装放进独立的 Runtime Manager;
- 第一次自动准备,第二次直接复用,并继续完成原来的搜索任务。
从架构上看,变化只有一句话:Tool 知道要做什么;Runtime Manager 确保它此刻做得起来。
如果还想继续,可以把 Manifest 增加一个 FFmpeg Artifact,但先别急着做全平台。只多支持一个 Binary,并把“下载失败”或“文件损坏”处理好,就已经是很扎实的下一步。