Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,47 @@ Add to your `opencode.json`:

Restart OpenCode. The plugin will be installed automatically.

## Proxy Configuration

You can configure outbound proxying in the plugin entry. The proxy is used only by this plugin's search requests; it does not affect OpenCode or the configured model provider. Use an absolute path for a local checkout because OpenCode does not expand `~` in plugin paths.

```json
{
"plugin": [
[
"/absolute/path/to/opencode-search",
{
"proxy": {
"socksProxy": "socks5://127.0.0.1:9050",
"noProxy": "localhost,127.0.0.1"
}
}
]
]
}
```

Use `httpProxy` and `httpsProxy` for standard HTTP or HTTPS proxies. If only one is set, it is used for both HTTP and HTTPS requests. `noProxy` contains an optional comma-separated bypass list. Use `socksProxy` for a SOCKS5 proxy; it takes precedence over the HTTP proxy settings.

As an alternative, set `OPENCODE_SEARCH_HTTP_PROXY`, `OPENCODE_SEARCH_HTTPS_PROXY`, `OPENCODE_SEARCH_SOCKS_PROXY`, or `OPENCODE_SEARCH_NO_PROXY` before starting OpenCode. Values in `opencode.json` take precedence. Generic variables such as `HTTP_PROXY` are deliberately ignored so this plugin cannot accidentally inherit OpenCode's proxy configuration.

When a SOCKS proxy is configured, the plugin checks Tor's official IP endpoint once at startup and caches the result. Over Tor:

- DuckDuckGo automatically uses its official onion `/html` endpoint.
- Wikipedia, MDN, and Bluesky use their normal APIs and have been verified to work.
- Google fails immediately with a clear error because it requires a JavaScript challenge over Tor.
- Standard.site may return an upstream 502; this has also occurred without Tor.

All proxied requests have a 20-second timeout and fail closed; they never retry over a direct connection.

For a local checkout, build the package and reference its directory in `opencode.json`. Remove any automatic `.opencode/plugins/` symlink for this plugin so it is not loaded twice:

```bash
cd /absolute/path/to/opencode-search
npm install
npm run build
```

## Tools

### ddg-search
Expand Down
46 changes: 34 additions & 12 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion opencode.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"theme": "opencode",
"plugin": ["opencode-search@0.0.8"],
"permission": {
"bash": {
"*": "ask"
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@
},
"dependencies": {
"@mozilla/readability": "0.5.0",
"jsdom": "26.1.0"
"jsdom": "26.1.0",
"socks": "2.8.7",
"undici-package": "npm:undici@7.28.0"
},
"devDependencies": {
"@opencode-ai/plugin": "1.1.12",
Expand Down
26 changes: 5 additions & 21 deletions search.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,8 @@
import type { Plugin } from '@opencode-ai/plugin'
import {
createBskySearchTool,
createDdgSearchTool,
createGoogleSearchTool,
createMdnSearchTool,
createStandardSearchTool,
createWikiSearchTool,
} from './src/search.ts'
import type { PluginInput } from '@opencode-ai/plugin'
import { createPlugin } from './src/search.ts'

const plugin: Plugin = async ({ client }) => {
return {
tool: {
'bsky-search': createBskySearchTool(client),
'ddg-search': createDdgSearchTool(client),
'ggl-search': createGoogleSearchTool(client),
'mdn-search': createMdnSearchTool(client),
'standard-search': createStandardSearchTool(client),
'wiki-search': createWikiSearchTool(client),
},
}
}
// Current OpenCode passes configured plugin options as the second argument.
// Keeping this as a function also preserves compatibility with legacy loaders.
const plugin = (input: PluginInput, options?: unknown) => createPlugin(input, options)

export default plugin
5 changes: 3 additions & 2 deletions src/providers/bsky-search.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod'
import * as cache from '../cache.ts'
import { getProxyCacheScope, proxyFetch } from '../proxy.ts'

const BSKY_API = 'https://api.bsky.app/xrpc/app.bsky.feed.searchPosts'

Expand Down Expand Up @@ -46,7 +47,7 @@ type BskySearchOptions = {

export const bskySearch = async (options: BskySearchOptions): Promise<BskySearchResult> => {
const cacheKey = [
'bsky',
getProxyCacheScope() + 'bsky',
options.query,
options.sort ?? 'latest',
options.limit ?? 25,
Expand All @@ -68,7 +69,7 @@ export const bskySearch = async (options: BskySearchOptions): Promise<BskySearch
}

const url = BSKY_API + '?' + params.toString()
const res = await fetch(url)
const res = await proxyFetch(url)

if (!res.ok) {
const text = await res.text()
Expand Down
81 changes: 60 additions & 21 deletions src/providers/ddg-search.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { JSDOM, VirtualConsole } from 'jsdom'
import * as cache from '../cache.ts'
import { getProxyCacheScope, getProxyStatus, isTorProxy, proxyFetch } from '../proxy.ts'

const DDG_URL = 'https://html.duckduckgo.com/html/'
const USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
const DDG_ONION_URL = 'https://duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion/html'
const USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'

export type DdgResult = {
title: string
Expand All @@ -11,31 +14,35 @@ export type DdgResult = {
}

export const ddgSearch = async (query: string): Promise<DdgResult[]> => {
const cacheKey = 'ddg:' + query
const cacheKey = getProxyCacheScope() + 'ddg:' + query

const cached = await cache.get<DdgResult[]>(cacheKey)
if (cached) {
return cached
}

const response = await fetch(DDG_URL, {
method: 'POST',
headers: {
'User-Agent': USER_AGENT,
'Accept-Encoding': 'gzip',
'Content-Type': 'application/x-www-form-urlencoded',
'DNT': '1',
},
body: new URLSearchParams({
q: query,
b: '',
kf: '-1',
kh: '1',
kl: 'us-en',
kp: '1',
k1: '-1',
}),
const params = new URLSearchParams({
q: query,
b: '',
kf: '-1',
kh: '1',
kl: 'us-en',
kp: '1',
k1: '-1',
})
const useOnion = await isTorProxy()
let responseUrl = useOnion ? DDG_ONION_URL : DDG_URL
let response = useOnion
? await fetchOnion(params)
: await fetchClearnet(params)

if (response.status === 403 && getProxyStatus().proxyType === 'socks') {
const blockedHtml = await response.text()
if (blockedHtml.includes('detected that you have connected over Tor')) {
responseUrl = DDG_ONION_URL
response = await fetchOnion(params)
}
}

if (!response.ok) {
throw new Error(response.status + ' ' + response.statusText)
Expand All @@ -53,7 +60,7 @@ export const ddgSearch = async (query: string): Promise<DdgResult[]> => {
// Silently ignore JSDOM errors
})

const dom = new JSDOM(html, { url: DDG_URL, virtualConsole })
const dom = new JSDOM(html, { url: responseUrl, virtualConsole })
const doc = dom.window.document

const links = doc.querySelectorAll('.result__a')
Expand All @@ -68,7 +75,7 @@ export const ddgSearch = async (query: string): Promise<DdgResult[]> => {
}

const title = link.textContent?.trim() ?? ''
const href = link.getAttribute('href') ?? ''
const href = normalizeResultUrl(link.getAttribute('href') ?? '', responseUrl)
const abstract = snippets[i]?.textContent?.trim() ?? ''

if (title && href) {
Expand All @@ -80,3 +87,35 @@ export const ddgSearch = async (query: string): Promise<DdgResult[]> => {

return results
}

const fetchClearnet = (params: URLSearchParams) =>
proxyFetch(DDG_URL, {
method: 'POST',
headers: {
'User-Agent': USER_AGENT,
'Accept-Encoding': 'gzip',
'Content-Type': 'application/x-www-form-urlencoded',
'DNT': '1',
},
body: params,
})

const fetchOnion = (params: URLSearchParams) =>
proxyFetch(DDG_ONION_URL + '?' + params.toString(), {
headers: {
'User-Agent': USER_AGENT,
'Accept-Encoding': 'gzip',
'DNT': '1',
},
})

const normalizeResultUrl = (href: string, baseUrl: string): string => {
if (!href) return ''

try {
const url = new URL(href, baseUrl)
return url.searchParams.get('uddg') ?? url.toString()
} catch {
return href
}
}
Loading