mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-27 19:48:48 +00:00
fix(docs): use Cloudflare docs search API
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user