diff --git a/docs/cli/docs.md b/docs/cli/docs.md index e17300fb99b..db1172bbe67 100644 --- a/docs/cli/docs.md +++ b/docs/cli/docs.md @@ -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 diff --git a/src/commands/docs.test.ts b/src/commands/docs.test.ts index 5f9ea270c3d..6e8f3e53751 100644 --- a/src/commands/docs.test.ts +++ b/src/commands/docs.test.ts @@ -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(); 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); diff --git a/src/commands/docs.ts b/src/commands/docs.ts index 9e1a7a7446a..8a86eb2797b 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -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 { + 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; + 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;