# Snow CLI User Guide - Custom Search Engine ## Overview Snow CLI's web search (the `web-search` MCP tool) is driven by a pluggable search engine layer. Built-in engines are `duckduckgo` and `bing`, both of which scrape results from a headless browser (no official API used). If you want to use a different search provider, you can drop a JavaScript file into your user directory and Snow CLI will register it automatically — no build step, no source code modification. Use this feature when you want to: - Use a regional search provider that isn't shipped by default - Search your company's internal knowledge base or intranet - Customize how an existing provider is scraped (e.g. fix a selector after a layout change) - Temporarily mask a built-in engine without deleting any file > The example below uses a fictional provider `example-search.com` purely > to illustrate the engine contract. You are responsible for complying with > each target site's Terms of Service and `robots.txt` when writing a real > plugin. ## Plugin Directory Snow CLI loads search engine plugins from: ```bash ~/.snow/plugin/search_engines/ ``` Supported file extensions: - `.js` - `.mjs` (recommended for plain ES Modules) - `.cjs` Notes: - Plugins are loaded from the user directory only. - Snow CLI sorts plugin files by filename and loads them on first web search. - Adding, modifying, or deleting plugin files hot-reloads automatically — no Snow CLI restart required. - Built-in engines (`duckduckgo`, `bing`) are always registered first; a plugin engine with the same `id` overrides the built-in one. ## Export Formats A plugin module can export in any of these forms (the first non-empty match wins, all of them are scanned): ```js export default { ... } ``` ```js export const searchEngine = { ... } ``` ```js export const searchEngines = [{ ... }, { ... }] ``` If multiple plugin files register the same engine `id`, the file loaded later (alphabetically) overrides the earlier one. ## Engine Structure Every engine must satisfy this shape (TypeScript-style for clarity, but plugin files are plain JavaScript): ```ts interface SearchEngine { id: string; // stable identifier, e.g. 'my-engine' name: string; // human readable, shown in the picker enable?: boolean; // optional, defaults to true search( page: Page, // a Puppeteer Page already opened for you query: string, // the user's query string maxResults: number, // how many results to return at most ): Promise; } interface SearchResult { title: string; url: string; snippet: string; displayUrl: string; } ``` Field description: - `id`: the value users put into `~/.snow/proxy-config.json`'s `searchEngine` field and what the picker stores. Keep it stable. - `name`: shown in the proxy config picker. Free-form. - `enable` (optional): defaults to `true`. Set to `false` to temporarily disable an engine without deleting its file. A disabled engine is invisible to `getSearchEngine`, `listSearchEngines`, and the UI picker. - Bonus trick: declaring `{id: 'bing', enable: false, search() {}}` in a plugin will mask the built-in `bing` engine, because the loader removes the same-id entry from the registry when it sees `enable: false`. - `search(page, query, maxResults)`: the actual work. Snow CLI: - launches/connects the browser for you (respects `~/.snow/proxy-config.json`) - opens a fresh `Page` and passes it in - closes the page after `search()` returns Your engine should: - navigate to its own search URL via `page.goto(...)` - wait for the DOM to settle - extract up to `maxResults` results via `page.evaluate(...)` - return them as an array of `SearchResult` Never call `browser.close()` / `page.close()` yourself — the page is owned by the caller. ## Lifecycle and Configuration 1. Drop the plugin file under `~/.snow/plugin/search_engines/`. 2. Start (or restart) Snow CLI. 3. Open the proxy configuration screen (`/settings` → Proxy and Browser Settings, or the dedicated entry point in your build) — your engine will appear in the "Search Engine" picker by its `name`. 4. Select your engine, save. The choice is persisted in `~/.snow/proxy-config.json` as: ```json { "enabled": false, "port": 7890, "searchEngine": "my-engine" } ``` 5. Any subsequent `web-search` MCP call will use your engine. ## Example: A Minimal Plugin Template Below is a complete, runnable template that targets a fictional provider `example-search.com`. Replace the URL, selectors, and id with the values that match your real target. Treat the selectors here as **placeholders** — every search page has a different DOM, you must inspect yours. ```js // ~/.snow/plugin/search_engines/my-engine.mjs const cleanText = text => (text || '') .replace(/\s+/g, ' ') .replace(/[\u200B-\u200D\uFEFF]/g, '') .trim(); export default { id: 'my-engine', name: 'My Search Engine', // Set to `false` to temporarily disable this engine without deleting the // file. Disabled engines are invisible to the picker and `getSearchEngine`. enable: true, async search(page, query, maxResults) { // 1. Build the search URL for your target provider. The example below // uses a fictional host purely to illustrate the shape. const encodedQuery = encodeURIComponent(query); const searchUrl = `https://example-search.com/search?q=${encodedQuery}` + `&n=${Math.max(maxResults, 10)}`; // 2. Navigate. Prefer `domcontentloaded` over `networkidle2` because // real search pages keep loading telemetry forever. try { await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: 30000, }); } catch { // Navigation timeout — try whatever already painted. } // 3. Wait for a representative result selector. Never throw — return // an empty list and let the caller fall back. try { await page.waitForSelector('.results .result-item', {timeout: 10000}); } catch { // Best effort — extraction may still find something. } // 4. Extract inside the browser context. const raw = await page.evaluate(maxLimit => { const out = []; const items = document.querySelectorAll('.results .result-item'); const isHttpUrl = u => /^https?:\/\//i.test(u); for (const item of items) { if (out.length >= maxLimit) break; // Filter ads if the provider marks them. if (item.classList.contains('is-ad')) continue; const linkEl = item.querySelector('a.result-title'); if (!linkEl) continue; const href = linkEl.getAttribute('href') || ''; if (!isHttpUrl(href)) continue; const title = (linkEl.textContent || '').trim(); if (!title) continue; const snippetEl = item.querySelector('.result-snippet'); const snippet = snippetEl ? (snippetEl.textContent || '').trim() : ''; const citeEl = item.querySelector('cite, .result-host'); const displayUrl = citeEl ? (citeEl.textContent || '').trim() : ''; out.push({title, url: href, snippet, displayUrl}); } return out; }, maxResults); // 5. Normalize and return. return raw.map(r => ({ title: cleanText(r.title), url: r.url || '', snippet: cleanText(r.snippet), displayUrl: cleanText(r.displayUrl), })); }, }; ``` To adapt this template to a real provider you need to figure out, for each provider you target: - the search URL pattern (often `?q=` or `?wd=` or `?query=`, plus a result-count parameter); - a stable container selector for organic results; - the title / link selector inside each container; - the snippet selector; - the display-URL / host selector; - how the provider marks ads or sponsored results, so you can skip them. Open the provider's result page in a regular browser, use DevTools to inspect the DOM, then plug the selectors into the template above. ## Writing Your Own Engine: Checklist 1. **Pick a stable `id`**. Once users save it into `proxy-config.json`, renaming will break their config. 2. **Open the target search URL with `domcontentloaded`**, not `networkidle2`. Most search pages keep loading telemetry scripts forever and `networkidle2` will time out before results are usable. 3. **Wrap `page.goto` in `try/catch`**. A navigation timeout is recoverable — the DOM may already contain enough to extract. 4. **Always use `page.waitForSelector` with a timeout**. Never `throw` if it fails; return an empty list and let the caller fall back. 5. **Extract inside `page.evaluate`**. The callback runs in the browser, so you have full DOM access but must `return` only structured-cloneable plain objects. 6. **Filter ads / sponsored results**. Each provider marks them differently — check the DOM yourself. 7. **Normalize text** (`cleanText` helper above) — collapse whitespace and strip zero-width characters. 8. **Never call `browser.close()` or `page.close()`**. The page is owned by `WebSearchService`. 9. **Don't import Node-only modules into `page.evaluate`'s callback** — it runs inside the browser. ## Multi-Engine Plugins You can register multiple engines from a single file: ```js export const searchEngines = [ {id: 'engine-a', name: 'Engine A', async search(...) { /* ... */ }}, {id: 'engine-b', name: 'Engine B', async search(...) { /* ... */ }}, ]; ``` This is convenient for plugins that share a `cleanText` helper or a common result-extraction routine. ## Troubleshooting - **The plugin does not appear in the picker.** - Make sure the file extension is `.js` / `.mjs` / `.cjs`. - Check the Snow CLI startup logs for `[websearch] failed to load search engine plugin "..."`. Syntax errors fail loudly. - Make sure your export is a plain object with `{id, name, search}` — the loader logs `did not export a valid SearchEngine` when validation fails. - **Search always returns 0 results.** - The provider probably updated its DOM. Open the page manually in a browser and inspect the new selectors. - Increase the `page.waitForSelector` timeout. - Some providers redirect bot traffic to a captcha page — try setting a realistic `User-Agent` via `page.setUserAgent(...)` at the start of `search()` (`WebSearchService` already sets one before delegating, but you can override). - **I want to disable a built-in engine.** - Create a plugin file with `{id: 'bing', name: 'Bing', enable: false, async search() { return []; }}`. The loader will see `enable: false` and remove the same-id entry from the registry. ## Related - [Proxy and Browser Settings](./03.Proxy%20and%20Browser%20Settings.md) - [Custom StatusLine Guide](./21.Custom%20StatusLine%20Guide.md) — same plugin-loading philosophy applied to the status line