今天的唯一目标

从一个因缺少 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。

text可复制后修改
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 版本。已经能执行下面命令时,不用重装:

powershell可复制后修改
node -v
npm -v
node -e "console.log(process.platform, process.arch)"

你应该看到类似结果:

text可复制后修改
v22.23.1
10.9.8
win32 x64

版本号可以更新,但最后一行必须是 win32 x64。如果是别的平台,请先把本文当作原理教程,不要照抄安装部分。

3. 创建项目,先把空房间搭好

在 PowerShell 中执行:

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

再创建这些空文件:

powershell可复制后修改
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

目录应该变成:

text可复制后修改
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

json可复制后修改
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "types": ["node"],
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src/**/*.ts"]
}

测试文件 demo/mcp-intro.md 写入:

markdown可复制后修改
# MCP 学习笔记

MCP 可以让 AI 应用发现并调用外部工具。

这个项目会实现一个 search_workspace Tool,用来搜索工作区里的文本。

后面我们还会搜索 registerTool 这个关键词。

到这里的成功标志: 文件结构齐全,node_modules 已出现,安装命令没有以红色错误结束。

4. 先写一个注定失败的搜索 Tool

ripgrep 是一个速度很快的文本搜索命令,真正执行时使用 rg。先问问电脑有没有它:

powershell可复制后修改
rg --version

如果 PowerShell 报“无法将 rg 识别为 cmdlet”,正好符合实验条件:

PowerShell 无法识别 rg,说明系统当前没有可直接调用的 ripgrep

如果你的电脑已经能返回版本号,不要卸载。后面我们会用一个只影响当前终端的开关,仍然可以体验托管版本。

先把 src/index.ts 写成最原始的版本:

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");

运行:

powershell可复制后修改
npx tsx src/index.ts

系统没有 rg 时,会得到 spawn rg ENOENT

最初的 search_workspace 直接 spawn rg,最终得到 ENOENT

代码知道要搜索,但真正的执行能力并不存在。这就是本篇的起点:有 Tool,不等于 Tool 运行得起来。

5. 第一次升级:让 Tool 声明“我需要什么”

最省事的写法,是直接把下载逻辑塞进 searchWorkspace()。但下一个 Tool 需要 Python,再下一个需要 FFmpeg 时,每个 Tool 都会长出一套安装器,场面很快从“自动化”滑向“自动添乱”。

更合适的做法,是让 Tool 只声明要求:

ts可复制后修改
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

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 才会使用它;否则继续准备托管版本。

检测顺序先尊重符合要求的系统版本,再看自己是否准备过。两处都没有时,日志会清楚停在这里:

Detector 先检查系统,再检查项目托管目录,两处都没有时明确停止

这一步还没有解决缺失问题,但已经把“直到 spawn 才突然爆炸”变成“执行前知道少了哪一层”。

7. Manifest:把模糊要求变成具体下载文件

Tool 只说 >=14,下载器却必须知道版本、操作系统、CPU 架构、URL 和校验值。把这些确定事实放进 runtime-manifest.json

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

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

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

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、限制来源,并尽可能验证发布者签名。

真实实验中,期望值和实际值一致:

实际 SHA256 与 Manifest 中的期望值一致,校验通过

到这里,ZIP 已经“下载成功 + 校验通过”,但它仍然不能直接当命令执行。下一步才是安装。

9. Installer:解压,再把 rg.exe 放进项目托管目录

把下面代码写入 src/runtime/installer.ts

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 后,再复制到稳定位置:

text可复制后修改
.runtime/
├─ downloads/
│  └─ ripgrep-14.1.1-x86_64-pc-windows-msvc.zip
├─ ripgrep/
│  └─ versions/
│     └─ 14.1.1/
│        └─ rg.exe
└─ staging/

安装完成后,托管的二进制文件能独立报告版本:

项目托管目录里的 rg.exe 能独立返回 ripgrep 14.1.1

注意:我们没有把它写进系统 PATH。Runtime Manager 最后会直接返回绝对路径,由 Tool 使用这个路径启动进程。

10. Manager:把五个零件串成 ensureBinary()

前面有 Detector、Manifest、Downloader、Verifier 和 Installer。现在把它们排成真正的执行顺序。

一次 Tool 调用依次经过声明、检测、解析、准备和执行

将下面代码写入 src/runtime/manager.ts

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 替换为:

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 检查:

powershell可复制后修改
npx tsc --noEmit

没有输出且回到提示符,就代表类型检查通过。

为了让已经安装 rg 的电脑也能复现托管分支,在当前 PowerShell 会话设置教学开关:

powershell可复制后修改
$env:RUNTIME_FORCE_MANAGED = "1"
npx tsx src/index.ts

第一次运行会依次看到:

text可复制后修改
[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. 第二次运行:不重复安装,直接复用

不要改任何代码,再运行一次:

powershell可复制后修改
npx tsx src/index.ts

这次 Runtime Manager 会找到 .runtime/ripgrep/versions/14.1.1/rg.exe,直接复用已有 Managed Binary:

第二次调用找到托管 ripgrep,不再下载和解压,直接继续搜索

两次运行的差别很清楚:

| 调用 | 当前状态 | 实际行为 | | --- | --- | --- | | 第一次 | System 没有 / Managed 没有 | Resolve → Download → Verify → Install → Execute | | 第二次 | System 没有 / Managed 已有 | Resolve → Reuse → Execute |

Runtime Manager 不是“每次都帮你重装”,而是“确保运行环境存在”。已经存在时,最正确的动作就是少折腾。

实验完成后清掉当前终端的教学开关:

powershell可复制后修改
Remove-Item Env:RUNTIME_FORCE_MANAGED

以后不设置它,代码会优先使用系统中已经存在的 rg

14. 为什么这比直接改 PATH 更舒服?

本篇没有把 rg.exe 安装到 Program Files,也没有永久修改系统 PATH。Tool 拿到的是一个具体文件路径,然后执行:

ts可复制后修改
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 依赖的真实执行环境存在。

它们可以这样合作:

text可复制后修改
用户提出任务
↓
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. 做完以后,我们真正学会了什么?

声明、校验、项目级托管与复用:最小 Runtime Manager 闭环完成

这次完成了三件事:

  1. 让 Tool 明确声明自己的运行时要求;
  2. 把下载、SHA256 校验和安装放进独立的 Runtime Manager;
  3. 第一次自动准备,第二次直接复用,并继续完成原来的搜索任务。

从架构上看,变化只有一句话:Tool 知道要做什么;Runtime Manager 确保它此刻做得起来。

如果还想继续,可以把 Manifest 增加一个 FFmpeg Artifact,但先别急着做全平台。只多支持一个 Binary,并把“下载失败”或“文件损坏”处理好,就已经是很扎实的下一步。

参考资料