fix(docs): use Cloudflare docs search API

This commit is contained in:
Peter Steinberger
2026-05-27 00:57:59 +01:00
parent 7e913c08f8
commit 69d84d775b
3 changed files with 90 additions and 177 deletions

View File

@@ -2,13 +2,13 @@
summary: "CLI reference for `openclaw docs` (search the live docs index)"
read_when:
- You want to search the live OpenClaw docs from the terminal
- You need to know which helper binaries the docs CLI shells out to
- You need to know which hosted search API the docs CLI calls
title: "Docs"
---
# `openclaw docs`
Search the live OpenClaw docs index from the terminal. The command shells out to the public Mintlify-hosted docs MCP search endpoint at `https://docs.openclaw.ai/mcp.search_open_claw` and renders the results in your terminal.
Search the live OpenClaw docs index from the terminal. The command calls OpenClaw's Cloudflare-hosted docs search API and renders the results in your terminal.
## Usage
@@ -35,17 +35,7 @@ With no query, `openclaw docs` prints the docs entrypoint URL plus a sample sear
## How it works
`openclaw docs` invokes the `mcporter` CLI to call the docs search MCP tool, then parses the `Title: / Link: / Content:` blocks from the tool output into a list of results.
To resolve `mcporter`, OpenClaw checks in order:
1. `mcporter` on `PATH` (used directly if present).
2. `pnpm dlx mcporter ...` if `pnpm` is installed.
3. `npx -y mcporter ...` if `npx` is installed.
If none are available, the command fails with a hint to install `pnpm` (`npm install -g pnpm`).
The search call uses a fixed 30 second timeout. Result snippets are truncated to ~220 characters per entry.
`openclaw docs` calls `https://docs.openclaw.ai/api/search` and renders the JSON results. The search call uses a fixed 30 second timeout.
## Output
@@ -62,10 +52,10 @@ In non-rich output (piped, `--no-color`, scripts), the same data renders as Mark
## Exit codes
| Code | Meaning |
| ---- | --------------------------------------------------- |
| `0` | Search succeeded (including zero-result responses). |
| `1` | The MCP tool call failed; stderr is printed inline. |
| Code | Meaning |
| ---- | ----------------------------------------------------------------- |
| `0` | Search succeeded (including zero-result responses). |
| `1` | The hosted docs search API call failed; stderr is printed inline. |
## Related

View File

@@ -1,16 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
const runCommandWithTimeout = vi.fn();
const hasBinary = vi.fn();
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout,
}));
vi.mock("../agents/skills.js", () => ({
hasBinary,
}));
const fetchMock = vi.fn<typeof fetch>();
vi.mock("../terminal/theme.js", () => ({
isRich: () => false,
@@ -46,49 +37,51 @@ function makeRuntime() {
describe("docsSearchCommand", () => {
beforeEach(() => {
runCommandWithTimeout.mockReset();
hasBinary.mockReset();
hasBinary.mockReturnValue(true);
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
it("invokes the correct lowercase docs MCP tool id", async () => {
runCommandWithTimeout.mockResolvedValueOnce({
code: 0,
stdout: "",
stderr: "",
});
it("calls the Cloudflare docs search API", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ results: [] }), {
headers: { "Content-Type": "application/json" },
}),
);
const runtime = makeRuntime();
await docsSearchCommand(["plugin", "allowlist"], runtime);
expect(runCommandWithTimeout).toHaveBeenCalledTimes(1);
const argv = runCommandWithTimeout.mock.calls[0][0] as string[];
const toolUrl = argv.find((arg) => arg.includes("docs.openclaw.ai/mcp."));
expect(toolUrl).toBe("https://docs.openclaw.ai/mcp.search_open_claw");
expect(toolUrl).not.toMatch(/SearchOpenClaw/);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe("https://docs.openclaw.ai/api/search?q=plugin+allowlist");
expect(init).toMatchObject({ headers: { Accept: "application/json" } });
});
it("fails loudly when mcporter returns a JSON-RPC MCP error on stdout with exit 0", async () => {
runCommandWithTimeout.mockResolvedValueOnce({
code: 0,
stdout: "MCP error -32602: Tool SearchOpenClaw not found",
stderr: "",
});
it("fails loudly when the Cloudflare docs search API fails", async () => {
fetchMock.mockResolvedValueOnce(new Response("nope", { status: 503 }));
const runtime = makeRuntime();
await docsSearchCommand(["browser", "existing-session"], runtime);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("MCP error -32602"));
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("HTTP 503"));
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("renders successful results when no MCP error is present", async () => {
runCommandWithTimeout.mockResolvedValueOnce({
code: 0,
stdout:
"Title: Plugin allowlist\nLink: https://docs.openclaw.ai/plugins/allowlist\nContent: How to configure the allowlist.",
stderr: "",
});
it("renders successful results from the Cloudflare docs search API", async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
results: [
{
title: "Plugin allowlist",
link: "https://docs.openclaw.ai/plugins/allowlist",
snippet: "How to configure the allowlist.",
},
],
}),
{ headers: { "Content-Type": "application/json" } },
),
);
const runtime = makeRuntime();
await docsSearchCommand(["plugin", "allowlist"], runtime);

View File

@@ -1,15 +1,10 @@
import { hasBinary } from "../agents/skills.js";
import { formatCliCommand } from "../cli/command-format.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
import { formatDocsLink } from "../terminal/links.js";
import { isRich, theme } from "../terminal/theme.js";
const SEARCH_TOOL = "https://docs.openclaw.ai/mcp.search_open_claw";
const SEARCH_API = "https://docs.openclaw.ai/api/search";
const SEARCH_TIMEOUT_MS = 30_000;
const DEFAULT_SNIPPET_MAX = 220;
const MCP_ERROR_PATTERN = /MCP error\s+-?\d+/i;
type DocResult = {
title: string;
@@ -17,103 +12,10 @@ type DocResult = {
snippet?: string;
};
type NodeRunner = {
cmd: string;
args: string[];
type DocsSearchResponse = {
results?: unknown;
};
type ToolRunOptions = {
input?: string;
timeoutMs?: number;
};
function resolveNodeRunner(): NodeRunner {
if (hasBinary("pnpm")) {
return { cmd: "pnpm", args: ["dlx"] };
}
if (hasBinary("npx")) {
return { cmd: "npx", args: ["-y"] };
}
throw new Error(
`Docs search needs pnpm or npx to run the docs search helper. Install pnpm, or run ${formatCliCommand("npm install -g pnpm")}.`,
);
}
async function runNodeTool(tool: string, toolArgs: string[], options: ToolRunOptions = {}) {
const runner = resolveNodeRunner();
const argv = [runner.cmd, ...runner.args, tool, ...toolArgs];
return await runCommandWithTimeout(argv, {
timeoutMs: options.timeoutMs ?? SEARCH_TIMEOUT_MS,
input: options.input,
});
}
async function runTool(tool: string, toolArgs: string[], options: ToolRunOptions = {}) {
if (hasBinary(tool)) {
return await runCommandWithTimeout([tool, ...toolArgs], {
timeoutMs: options.timeoutMs ?? SEARCH_TIMEOUT_MS,
input: options.input,
});
}
return await runNodeTool(tool, toolArgs, options);
}
function extractLine(lines: string[], prefix: string): string | undefined {
const line = lines.find((value) => value.startsWith(prefix));
if (!line) {
return undefined;
}
return line.slice(prefix.length).trim();
}
function normalizeSnippet(raw: string | undefined, fallback: string): string {
const base = raw && raw.trim().length > 0 ? raw : fallback;
const cleaned = base.replace(/\s+/g, " ").trim();
if (!cleaned) {
return "";
}
if (cleaned.length <= DEFAULT_SNIPPET_MAX) {
return cleaned;
}
return `${cleaned.slice(0, DEFAULT_SNIPPET_MAX - 3)}...`;
}
function firstParagraph(text: string): string {
return (
text
.split(/\n\s*\n/)
.map((chunk) => chunk.trim())
.find(Boolean) ?? ""
);
}
function parseSearchOutput(raw: string): DocResult[] {
const normalized = raw.replace(/\r/g, "");
const blocks = normalizeStringEntries(normalized.split(/\n(?=Title: )/g));
const results: DocResult[] = [];
for (const block of blocks) {
const lines = block.split("\n");
const title = extractLine(lines, "Title:");
const link = extractLine(lines, "Link:");
if (!title || !link) {
continue;
}
const content = extractLine(lines, "Content:");
const contentIndex = lines.findIndex((line) => line.startsWith("Content:"));
const body =
contentIndex >= 0
? lines
.slice(contentIndex + 1)
.join("\n")
.trim()
: "";
const snippet = normalizeSnippet(content, firstParagraph(body));
results.push({ title, link, snippet: snippet || undefined });
}
return results;
}
function escapeMarkdown(text: string): string {
return text.replace(/[()[\]]/g, "\\$&");
}
@@ -159,6 +61,49 @@ async function renderMarkdown(markdown: string, runtime: RuntimeEnv) {
runtime.log(markdown.trimEnd());
}
async function fetchDocsSearch(query: string): Promise<DocResult[]> {
const url = new URL(SEARCH_API);
url.searchParams.set("q", query);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = (await response.json()) as DocsSearchResponse;
return parseDocsSearchResults(payload.results);
} finally {
clearTimeout(timeout);
}
}
function parseDocsSearchResults(raw: unknown): DocResult[] {
if (!Array.isArray(raw)) {
return [];
}
const results: DocResult[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") {
continue;
}
const entry = item as Record<string, unknown>;
if (typeof entry.title !== "string" || typeof entry.link !== "string") {
continue;
}
results.push({
title: entry.title,
link: entry.link,
snippet:
typeof entry.snippet === "string" && entry.snippet.trim() ? entry.snippet : undefined,
});
}
return results;
}
export async function docsSearchCommand(queryParts: string[], runtime: RuntimeEnv) {
const query = queryParts.join(" ").trim();
if (!query) {
@@ -173,31 +118,16 @@ export async function docsSearchCommand(queryParts: string[], runtime: RuntimeEn
return;
}
const payload = JSON.stringify({ query });
const res = await runTool(
"mcporter",
["call", SEARCH_TOOL, "--args", payload, "--output", "text"],
{ timeoutMs: SEARCH_TIMEOUT_MS },
);
if (res.code !== 0) {
const err = res.stderr.trim() || res.stdout.trim() || `exit ${res.code}`;
runtime.error(`Docs search failed: ${err}`);
let results: DocResult[];
try {
results = await fetchDocsSearch(query);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtime.error(`Docs search failed: ${message}`);
runtime.exit(1);
return;
}
const combined = `${res.stdout}\n${res.stderr}`;
if (MCP_ERROR_PATTERN.test(combined)) {
const err = (res.stderr.trim() || res.stdout.trim())
.split("\n")
.find((line) => MCP_ERROR_PATTERN.test(line));
runtime.error(`Docs search failed: ${err ?? "MCP error reported by docs search tool"}`);
runtime.exit(1);
return;
}
const results = parseSearchOutput(res.stdout);
if (isRich()) {
renderRichResults(query, results, runtime);
return;