From 1ae5e9a26b4fc8e40aed10923d3e563e5227a927 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 02:03:06 +0100 Subject: [PATCH] feat: add docs search command --- CHANGELOG.md | 1 + docs/start/faq.md | 20 +++-- src/cli/docs-cli.ts | 19 +++++ src/cli/program.ts | 2 + src/commands/docs.ts | 169 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 src/cli/docs-cli.ts create mode 100644 src/commands/docs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 694583e3d..1b59bba2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### Fixes - Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect. +- CLI: add `clawdbot docs` live docs search with pretty output. - Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341. - Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298. - Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior. diff --git a/docs/start/faq.md b/docs/start/faq.md index d03b274d4..0dbc1e7a5 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -3,7 +3,7 @@ summary: "Frequently asked questions about Clawdbot setup, configuration, and us --- # FAQ 🦞 -Common questions from the community. For detailed configuration, see [Configuration](/configuration). +Common questions from the community. For detailed configuration, see [Configuration](/gateway/configuration). ## Installation & Setup @@ -62,7 +62,7 @@ wsl --install Then open Ubuntu and run the normal Getting Started steps. -Full guide: [Windows (WSL2)](/windows) +Full guide: [Windows (WSL2)](/platforms/windows) ### How do I install on Linux without Homebrew? @@ -111,6 +111,16 @@ pnpm clawdbot doctor It checks your config, skills status, and gateway health. It can also restart the gateway daemon if needed. +### How do I search the docs quickly? + +Use the CLI docs search (live docs): + +```bash +clawdbot docs "gateway lock" +``` + +The first run will fetch the helper CLIs if they are missing. + ### Terminal onboarding vs macOS app? **Use terminal onboarding** (`pnpm clawdbot onboard`) — it's more stable right now. @@ -319,7 +329,7 @@ Per-group activation can be changed by the owner: - `/activation mention` — respond only when mentioned (default) - `/activation always` — respond to all messages -See [Groups](/groups) for details. +See [Groups](/concepts/groups) for details. --- @@ -356,7 +366,7 @@ cat ~/.clawdbot/clawdbot.json | grep workspace - **Telegram** — Via Bot API (grammY). - **Discord** — Bot integration. - **iMessage** — Via `imsg` CLI (macOS only). -- **Signal** — Via `signal-cli` (see [Signal](/signal)). +- **Signal** — Via `signal-cli` (see [Signal](/providers/signal)). - **WebChat** — Browser-based chat UI. ### Discord: Bot works in channels but not DMs? @@ -599,7 +609,7 @@ Quick reference (send these in chat): Slash commands are owner-only (gated by `whatsapp.allowFrom` and command authorization on other surfaces). Commands are only recognized when the entire message is the command (slash required; no plain-text aliases). -Full list + config: [Slash commands](/slash-commands) +Full list + config: [Slash commands](/tools/slash-commands) ### How do I switch models on the fly? diff --git a/src/cli/docs-cli.ts b/src/cli/docs-cli.ts new file mode 100644 index 000000000..0afc26424 --- /dev/null +++ b/src/cli/docs-cli.ts @@ -0,0 +1,19 @@ +import type { Command } from "commander"; + +import { docsSearchCommand } from "../commands/docs.js"; +import { defaultRuntime } from "../runtime.js"; + +export function registerDocsCli(program: Command) { + program + .command("docs") + .description("Search the live Clawdbot docs") + .argument("[query...]", "Search query") + .action(async (queryParts: string[]) => { + try { + await docsSearchCommand(queryParts, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 6f03c8492..6f5f40d3c 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -28,6 +28,7 @@ import { registerBrowserCli } from "./browser-cli.js"; import { registerCanvasCli } from "./canvas-cli.js"; import { registerCronCli } from "./cron-cli.js"; import { createDefaultDeps } from "./deps.js"; +import { registerDocsCli } from "./docs-cli.js"; import { registerDnsCli } from "./dns-cli.js"; import { registerGatewayCli } from "./gateway-cli.js"; import { registerHooksCli } from "./hooks-cli.js"; @@ -543,6 +544,7 @@ Examples: registerTuiCli(program); registerCronCli(program); registerDnsCli(program); + registerDocsCli(program); registerHooksCli(program); registerPairingCli(program); registerTelegramCli(program); diff --git a/src/commands/docs.ts b/src/commands/docs.ts new file mode 100644 index 000000000..04b326a34 --- /dev/null +++ b/src/commands/docs.ts @@ -0,0 +1,169 @@ +import { hasBinary } from "../agents/skills.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import type { RuntimeEnv } from "../runtime.js"; + +const SEARCH_TOOL = "https://docs.clawd.bot/mcp.SearchClawdbot"; +const SEARCH_TIMEOUT_MS = 30_000; +const RENDER_TIMEOUT_MS = 10_000; +const DEFAULT_SNIPPET_MAX = 220; + +type DocResult = { + title: string; + link: string; + snippet?: string; +}; + +type NodeRunner = { + cmd: string; + args: string[]; +}; + +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("Missing pnpm or npx; install a Node package runner."); +} + +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 { + const parts = text + .split(/\n\s*\n/) + .map((chunk) => chunk.trim()) + .filter(Boolean); + return parts[0] ?? ""; +} + +function parseSearchOutput(raw: string): DocResult[] { + const normalized = raw.replace(/\r/g, ""); + const blocks = normalized + .split(/\n(?=Title: )/g) + .map((chunk) => chunk.trim()) + .filter(Boolean); + + 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, "\\$&"); +} + +function buildMarkdown(query: string, results: DocResult[]): string { + const lines: string[] = [`# Docs search: ${escapeMarkdown(query)}`, ""]; + if (results.length === 0) { + lines.push("_No results._"); + return lines.join("\n"); + } + for (const item of results) { + const title = escapeMarkdown(item.title); + const snippet = item.snippet ? escapeMarkdown(item.snippet) : ""; + const suffix = snippet ? ` - ${snippet}` : ""; + lines.push(`- [${title}](${item.link})${suffix}`); + } + return lines.join("\n"); +} + +async function renderMarkdown(markdown: string, runtime: RuntimeEnv) { + const width = process.stdout.columns ?? 0; + const args = width > 0 ? ["--width", String(width)] : []; + try { + const res = await runTool("markdansi", args, { + timeoutMs: RENDER_TIMEOUT_MS, + input: markdown, + }); + if (res.code === 0 && res.stdout.trim()) { + runtime.log(res.stdout.trimEnd()); + return; + } + } catch { + // Fall back to plain Markdown if renderer fails or cannot be installed. + } + runtime.log(markdown.trimEnd()); +} + +export async function docsSearchCommand( + queryParts: string[], + runtime: RuntimeEnv, +) { + const query = queryParts.join(" ").trim(); + if (!query) { + runtime.log("Docs: https://docs.clawd.bot/"); + runtime.log('Search: clawdbot docs "your query"'); + 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}`); + runtime.exit(1); + return; + } + + const results = parseSearchOutput(res.stdout); + const markdown = buildMarkdown(query, results); + await renderMarkdown(markdown, runtime); +}