feat: add docs search command

This commit is contained in:
Peter Steinberger
2026-01-07 02:03:06 +01:00
parent e816991dc5
commit 1ae5e9a26b
5 changed files with 206 additions and 5 deletions

View File

@@ -17,6 +17,7 @@
### Fixes ### Fixes
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect. - 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. - 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. - 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. - Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior.

View File

@@ -3,7 +3,7 @@ summary: "Frequently asked questions about Clawdbot setup, configuration, and us
--- ---
# FAQ 🦞 # 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 ## Installation & Setup
@@ -62,7 +62,7 @@ wsl --install
Then open Ubuntu and run the normal Getting Started steps. 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? ### 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. 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? ### Terminal onboarding vs macOS app?
**Use terminal onboarding** (`pnpm clawdbot onboard`) — it's more stable right now. **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 mention` — respond only when mentioned (default)
- `/activation always` — respond to all messages - `/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). - **Telegram** — Via Bot API (grammY).
- **Discord** — Bot integration. - **Discord** — Bot integration.
- **iMessage** — Via `imsg` CLI (macOS only). - **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. - **WebChat** — Browser-based chat UI.
### Discord: Bot works in channels but not DMs? ### 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). 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). 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? ### How do I switch models on the fly?

19
src/cli/docs-cli.ts Normal file
View File

@@ -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);
}
});
}

View File

@@ -28,6 +28,7 @@ import { registerBrowserCli } from "./browser-cli.js";
import { registerCanvasCli } from "./canvas-cli.js"; import { registerCanvasCli } from "./canvas-cli.js";
import { registerCronCli } from "./cron-cli.js"; import { registerCronCli } from "./cron-cli.js";
import { createDefaultDeps } from "./deps.js"; import { createDefaultDeps } from "./deps.js";
import { registerDocsCli } from "./docs-cli.js";
import { registerDnsCli } from "./dns-cli.js"; import { registerDnsCli } from "./dns-cli.js";
import { registerGatewayCli } from "./gateway-cli.js"; import { registerGatewayCli } from "./gateway-cli.js";
import { registerHooksCli } from "./hooks-cli.js"; import { registerHooksCli } from "./hooks-cli.js";
@@ -543,6 +544,7 @@ Examples:
registerTuiCli(program); registerTuiCli(program);
registerCronCli(program); registerCronCli(program);
registerDnsCli(program); registerDnsCli(program);
registerDocsCli(program);
registerHooksCli(program); registerHooksCli(program);
registerPairingCli(program); registerPairingCli(program);
registerTelegramCli(program); registerTelegramCli(program);

169
src/commands/docs.ts Normal file
View File

@@ -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);
}