surf-skill 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,77 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.1.0 — Brave Search as 3rd provider + `--mode` flag
4
+
5
+ ### What's new
6
+
7
+ - **Brave Search added as 3rd provider** (`--provider brave`). Brave runs
8
+ its own index (independent from Google/Bing) and currently offers $5/mo
9
+ in API credit + metered usage (~$0.003/query) after the free tier was
10
+ retired in Feb 2026. Search only — Brave has no extract/crawl/map/
11
+ research equivalents, so the capability map keeps those Tavily-only.
12
+ - **New `--mode <fast|normal|slow>` flag** for `search`. Each provider
13
+ translates the mode to its native tier:
14
+
15
+ | Mode | Tavily | Parallel | Brave |
16
+ |---|---|---|---|
17
+ | `fast` | `search_depth=fast` | (ignored) | `count=5` |
18
+ | `normal` | `search_depth=basic` | `/v1/search` | `count=10` |
19
+ | `slow` | `search_depth=advanced` | (ignored) | `count=20` |
20
+
21
+ `--depth basic|advanced` continues to work as a legacy alias for Tavily.
22
+
23
+ - **Library API gets `opts.mode`, `opts.braveKey`, `opts.braveKeys`**:
24
+ ```js
25
+ await search('claude api', { mode: 'fast', braveKey: 'BS...' });
26
+ ```
27
+
28
+ - **`BRAVE_API_KEY` / `BRAVE_API_KEYS` env vars** now part of the discovery
29
+ hierarchy (env > .env > ~/.config/surf/keys.json).
30
+
31
+ - **Setup wizard** now prompts for Brave keys after Tavily + Parallel.
32
+
33
+ - **State migration is transparent**: existing `~/.config/surf/keys.json`
34
+ files from v2.0.x get a `brave` section added automatically on next
35
+ `loadState()` — no manual upgrade step needed.
36
+
37
+ ### Default chain order
38
+
39
+ `search` chain is now `[tavily, parallel, brave]`. Existing users see no
40
+ behavior change (Tavily still tried first); Brave is the 3rd fallback.
41
+ `last_ok_provider` still wins.
42
+
43
+ ### Breaking changes
44
+
45
+ None. CLI and library APIs are backward compatible.
46
+
47
+ ### Files added
48
+
49
+ - `src/lib/providers/brave.mjs` — adapter, `mapError()`, `/web/search`
50
+ with mode → count translation.
51
+
52
+ ### Files modified
53
+
54
+ - `src/lib/providers/index.mjs` — register brave + add to capabilityMap.search
55
+ - `src/lib/state.mjs` — `PROVIDERS = ['tavily', 'parallel', 'brave']` +
56
+ `normalizeFullState()` for graceful schema migration
57
+ - `src/env.mjs` — `discoverKeys()` returns `{ tavily, parallel, brave }`
58
+ - `src/lib/cost.mjs` — `estimateBrave()` returns 1 credit/search
59
+ - `src/lib/setup.mjs` — 3-provider wizard
60
+ - `src/lib/providers/tavily.mjs` — mode → search_depth resolution
61
+ - `src/lib/api/search.mjs` — library opts.mode
62
+ - `bin/surf-skill.mjs` — HELP + `--mode` flag wiring
63
+ - `src/lib/harness-install.mjs` — skeleton with brave section
64
+ - `package.json`, `SKILL.md`, `README.md` — bump 2.0.0 → 2.1.0, doc updates
65
+
66
+ ### Fora de escopo
67
+
68
+ - Brave `/summarizer/search` endpoint — defer to v2.2 (different rate
69
+ limit, response shape adds `data.answer`).
70
+ - Brave Goggles support (`--goggle <id>`) — defer.
71
+ - News / Images / Videos / Local / Spellcheck endpoints — defer.
72
+
73
+ ---
74
+
3
75
  ## v2.0.0 — npm package, cross-OS install, library mode
4
76
 
5
77
  ### What's new
package/README.md CHANGED
@@ -1,24 +1,40 @@
1
- # surf-skill
1
+ <p align="center">
2
+ <img src="logo.png" alt="surf-skill logo" width="160" />
3
+ </p>
2
4
 
3
- **One bash command. Two providers. Zero MCP.** A multi-provider web skill
4
- for AI coding agents that fronts **Tavily** and **Parallel AI** behind a
5
- single CLI (`surf-skill`). The agent calling this skill **never picks the
6
- provider** `surf-skill` does, with automatic key rotation, provider
7
- fallback, and last-known-good persistence.
5
+ <h1 align="center">surf-skill</h1>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/surf-skill"><img src="https://img.shields.io/npm/v/surf-skill?style=flat-square&color=black" alt="npm" /></a>
9
+ <a href="https://www.npmjs.com/package/surf-skill"><img src="https://img.shields.io/npm/dt/surf-skill?style=flat-square&color=black" alt="downloads" /></a>
10
+ <a href="LICENSE"><img src="https://img.shields.io/npm/l/surf-skill?style=flat-square&color=black" alt="MIT" /></a>
11
+ <img src="https://img.shields.io/node/v/surf-skill?style=flat-square&color=black" alt="node>=18" />
12
+ </p>
13
+
14
+ <p align="center">
15
+ Multi-provider web skill for AI coding agents.<br/>
16
+ Fronts <strong>Tavily</strong> and <strong>Parallel AI</strong> behind a single CLI + Node library, with automatic key rotation, provider fallback, and last-known-good persistence.
17
+ </p>
18
+
19
+ ---
20
+
21
+ **One command. Three providers. Zero MCP.** Install with `npm i -g surf-skill`.
22
+ The agent calling this skill **never picks the provider** — `surf-skill` does.
8
23
 
9
24
  ```
10
- search ─┐
11
- extract ┤ ┌──▶ Tavily (search, extract, crawl, map, research)
12
- crawl ──┼──▶ surf ───┤
13
- map ───┤ └──▶ Parallel (search, extract, research async)
14
- research┘
25
+ search ─┐ ┌──▶ Tavily (search, extract, crawl, map, research)
26
+ extract ┤
27
+ crawl ──┼──▶ surf ───┼──▶ Parallel (search, extract, research async)
28
+ map ───┤
29
+ research┘ └──▶ Brave (search only — own index)
15
30
  ```
16
31
 
17
32
  | | |
18
33
  |---|---|
19
- | **Status** | v1.0.0 |
20
- | **Runtime** | Node 18, bash. Zero npm deps. |
21
- | **Storage** | `~/.config/surf/keys.json` (chmod 600). Never read from env at runtime. |
34
+ | **Status** | v2.1.0 (npm) |
35
+ | **Install** | `npm i -g surf-skill` (Linux · macOS · Windows) |
36
+ | **Runtime** | Node 18. Zero npm deps. |
37
+ | **Storage** | `~/.config/surf/keys.json` (chmod 600). Never read from env at runtime by the CLI. |
22
38
  | **Supported agents** | Claude Code · GitHub Copilot CLI · Pi Coding Agent · OpenCode · Codex CLI |
23
39
  | **Spec** | [Anthropic Agent Skills](https://docs.claude.com/en/docs/agents-and-tools/agent-skills) |
24
40
 
@@ -200,7 +216,7 @@ If you see timeouts, the order of fixes:
200
216
  |---|---|---|
201
217
  | `setup` | Interactive wizard to add keys (TTY) | n/a |
202
218
  | `project-config` | Write per-project bash-timeout config | n/a |
203
- | `search <q> [q2 ...]` | Web search; multiple positional args = **batch** | tavily, parallel |
219
+ | `search <q> [q2 ...]` | Web search; multiple positional args = **batch** | tavily, parallel, **brave** |
204
220
  | `extract <url> ...` | Pull markdown from URLs | tavily, parallel |
205
221
  | `crawl <url>` | Recursive site crawl | tavily |
206
222
  | `map <url>` | Sitemap discovery | tavily |
@@ -217,13 +233,33 @@ Full reference: `skills/surf-skill/SKILL.md`.
217
233
  Global flags every command accepts:
218
234
 
219
235
  ```
220
- --provider <tavily|parallel> Force provider (disables fallback)
221
- --no-fallback Keep default provider, no cross-provider fallback
222
- --no-cache Skip response cache
223
- --json Normalized envelope as JSON
224
- --raw-json Raw provider response (bypasses cache)
225
- --confirm-expensive Allow operations estimated > 10 credits
226
- --quiet Silence progress logs (stderr)
236
+ --provider <tavily|parallel|brave> Force provider (disables fallback)
237
+ --mode <fast|normal|slow> Search tier. Per-provider mapping:
238
+ fast = Tavily depth=fast / Brave count=5
239
+ normal = default
240
+ slow = Tavily depth=advanced / Brave count=20
241
+ (Parallel ignores single mode.)
242
+ --no-fallback Keep default provider, no cross-provider fallback
243
+ --no-cache Skip response cache
244
+ --json Normalized envelope as JSON
245
+ --raw-json Raw provider response (bypasses cache)
246
+ --confirm-expensive Allow operations estimated > 10 credits
247
+ --quiet Silence progress logs (stderr)
248
+ ```
249
+
250
+ ### Search modes
251
+
252
+ ```bash
253
+ surf-skill search "X" --mode fast # 5 results / 1 credit Tavily / minimal latency
254
+ surf-skill search "X" --mode normal # 10 results / default everywhere
255
+ surf-skill search "X" --mode slow # 20 results / Tavily advanced / deeper signal
256
+ ```
257
+
258
+ Want to force a specific provider for a given mode?
259
+
260
+ ```bash
261
+ surf-skill search "X" --provider brave --mode slow # 20 brave results, no fallback
262
+ surf-skill search "X" --provider tavily --mode fast # Tavily fast tier
227
263
  ```
228
264
 
229
265
  ---
package/SKILL.md CHANGED
@@ -39,6 +39,7 @@ Or non-interactive:
39
39
  ```bash
40
40
  surf-skill keys add --provider tavily tvly-...
41
41
  surf-skill keys add --provider parallel <key>
42
+ surf-skill keys add --provider brave <key>
42
43
  ```
43
44
 
44
45
  Keys live in `~/.config/surf/keys.json` (chmod 600) — never read from env at
@@ -52,21 +53,34 @@ The connector decides which provider to call based on:
52
53
  3. Which keys are healthy (`burned` keys are skipped, auto-reset monthly).
53
54
 
54
55
  Force a specific provider **only for debugging** with
55
- `--provider tavily|parallel`. That disables fallback — failure means failure.
56
+ `--provider tavily|parallel|brave`. That disables fallback — failure means failure.
56
57
 
57
58
  ## Capability table
58
59
 
59
- | Operation | Tavily | Parallel | Default order |
60
- |---|---|---|---|
61
- | `search` | ✓ | ✓ | tavily → parallel |
62
- | `extract` | ✓ | ✓ | tavily → parallel |
63
- | `crawl` | ✓ | ✗ | tavily only |
64
- | `map` | ✓ | ✗ | tavily only |
65
- | `research-start` / `research` | ✓ | ✓ | parallel → tavily |
66
- | `research-poll` | by `request_id` prefix | by `request_id` prefix | sticky |
60
+ | Operation | Tavily | Parallel | Brave | Default order |
61
+ |---|---|---|---|---|
62
+ | `search` | ✓ | ✓ | ✓ | tavily → parallel → brave |
63
+ | `extract` | ✓ | ✓ | ✗ | tavily → parallel |
64
+ | `crawl` | ✓ | ✗ | ✗ | tavily only |
65
+ | `map` | ✓ | ✗ | ✗ | tavily only |
66
+ | `research-start` / `research` | ✓ | ✓ | ✗ | parallel → tavily |
67
+ | `research-poll` | by `request_id` prefix | by `request_id` prefix | (n/a) | sticky |
67
68
 
68
69
  When `last_ok_provider` is in the chain, it is promoted to the front.
69
70
 
71
+ ## Search modes (`--mode`)
72
+
73
+ `--mode <fast|normal|slow>` is the canonical search-tier flag. Each provider
74
+ maps it differently:
75
+
76
+ | Mode | Tavily | Parallel | Brave |
77
+ |---|---|---|---|
78
+ | `fast` | `search_depth=fast` (1 credit, ~1-3 s) | (ignored) | `count=5` (5 results, fastest) |
79
+ | `normal` (default) | `search_depth=basic` (1 credit, ~2 s) | `/v1/search` | `count=10` (10 results) |
80
+ | `slow` | `search_depth=advanced` (2 credits, ~5 s) | (ignored) | `count=20` (20 results) |
81
+
82
+ `--depth basic|advanced` continues to work as a legacy alias for Tavily users.
83
+
70
84
  ## Timeouts per harness — IMPORTANT
71
85
 
72
86
  This skill runs as a bash command. Each agent harness has its own default
@@ -143,6 +157,7 @@ surf-skill research "narrow question" --model mini --confirm-expensive
143
157
  # Keys management
144
158
  surf-skill keys add --provider tavily tvly-...
145
159
  surf-skill keys add --provider parallel <key>
160
+ surf-skill keys add --provider brave <key>
146
161
  surf-skill keys list
147
162
  surf-skill keys remove --provider tavily 0
148
163
  surf-skill keys reset # un-burn all keys
@@ -15,7 +15,7 @@ import { runProjectConfig, formatProjectConfigResult } from '../src/lib/project-
15
15
  import { providerFromRequestId } from '../src/lib/providers/index.mjs';
16
16
  import { progress, setSilent } from '../src/lib/progress.mjs';
17
17
 
18
- const VERSION = '2.0.0';
18
+ const VERSION = '2.1.0';
19
19
 
20
20
  // Catch SIGTERM/SIGINT so a harness-driven kill surfaces a useful message
21
21
  // instead of dying silently. This is defense-in-depth: dispatch already
@@ -54,7 +54,12 @@ Commands:
54
54
  keys <add|remove|list|reset|clear> [...]
55
55
 
56
56
  Global flags:
57
- --provider <tavily|parallel> Force provider, disables fallback
57
+ --provider <tavily|parallel|brave> Force provider, disables fallback
58
+ --mode <fast|normal|slow> Search tier (per-provider mapping):
59
+ fast = Tavily depth=fast / Brave count=5
60
+ normal = Tavily depth=basic / Brave count=10 (default)
61
+ slow = Tavily depth=advanced / Brave count=20
62
+ Parallel ignores (single mode).
58
63
  --no-fallback Stay on default provider, no cross-provider fallback
59
64
  --no-cache Skip response cache
60
65
  --json Print normalized envelope as JSON
@@ -112,9 +117,13 @@ function emitResult(envelope, flags) {
112
117
  }
113
118
 
114
119
  function buildSearchArgs(query, flags) {
120
+ // --mode is the canonical flag. --depth (Tavily-ism) is still accepted as
121
+ // legacy alias; if neither is set, default to depth='advanced' (Tavily) which
122
+ // also resolves to mode='slow' on Brave.
115
123
  return {
116
124
  query,
117
- depth: flags.depth || 'advanced',
125
+ mode: flags.mode,
126
+ depth: flags.depth || (flags.mode ? undefined : 'advanced'),
118
127
  max: flags.max,
119
128
  topic: flags.topic,
120
129
  time: flags.time,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "surf-skill",
3
- "version": "2.0.0",
4
- "description": "Multi-provider web skill (Tavily + Parallel AI) for AI coding agents — CLI + Node library + Anthropic Agent Skill. Auto-fallback, multi-key rotation, per-project timeout config.",
3
+ "version": "2.1.0",
4
+ "description": "Multi-provider web skill (Tavily + Parallel AI + Brave Search) for AI coding agents — CLI + Node library + Anthropic Agent Skill. Auto-fallback, multi-key rotation, --mode tiers, per-project timeout config.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
7
7
  "exports": {
@@ -32,6 +32,7 @@
32
32
  "agent-skill",
33
33
  "tavily",
34
34
  "parallel-ai",
35
+ "brave-search",
35
36
  "web-search",
36
37
  "connector",
37
38
  "claude-code",
package/src/env.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  // Key discovery for library mode.
2
2
  // Priority (each level can contribute; results merged + deduped):
3
- // 1. Explicit opts (opts.tavilyKey / opts.tavilyKeys / parallel*)
4
- // 2. process.env (TAVILY_API_KEYS comma-separated + TAVILY_API_KEY)
3
+ // 1. Explicit opts (opts.tavilyKey / opts.tavilyKeys / parallel* / brave*)
4
+ // 2. process.env (TAVILY_API_KEYS comma-separated + TAVILY_API_KEY,
5
+ // PARALLEL_API_KEYS + PARALLEL_API_KEY,
6
+ // BRAVE_API_KEYS + BRAVE_API_KEY)
5
7
  // 3. .env file at process.cwd() (lightweight regex parser, no dotenv dep)
6
8
  // 4. ~/.config/surf/keys.json (CLI persistent store, fallback only)
7
9
 
@@ -39,73 +41,73 @@ function arrayify(v) {
39
41
  return Array.isArray(v) ? v.filter(Boolean) : [v].filter(Boolean);
40
42
  }
41
43
 
44
+ function readFromObject(obj, base) {
45
+ // base = 'TAVILY' | 'PARALLEL' | 'BRAVE'
46
+ return [
47
+ ...splitCsv(obj[`${base}_API_KEYS`]),
48
+ obj[`${base}_API_KEY`],
49
+ ].filter(Boolean);
50
+ }
51
+
42
52
  /**
43
- * Resolve API keys for both providers using the discovery hierarchy.
53
+ * Resolve API keys for all 3 providers using the discovery hierarchy.
44
54
  *
45
55
  * @param {object} opts
46
- * @param {string|string[]} [opts.tavilyKey] - single key or array
47
- * @param {string[]} [opts.tavilyKeys] - array (alias)
48
- * @param {string|string[]} [opts.parallelKey] - single or array
49
- * @param {string[]} [opts.parallelKeys] - array (alias)
50
- * @param {boolean} [opts.skipDotenv=false] - skip .env scanning
51
- * @param {boolean} [opts.skipConfigFile=false] - skip ~/.config/surf/keys.json
56
+ * @param {string|string[]} [opts.tavilyKey|opts.tavilyKeys]
57
+ * @param {string|string[]} [opts.parallelKey|opts.parallelKeys]
58
+ * @param {string|string[]} [opts.braveKey|opts.braveKeys]
59
+ * @param {boolean} [opts.skipDotenv=false]
60
+ * @param {boolean} [opts.skipConfigFile=false]
52
61
  * @param {string} [opts.cwd=process.cwd()]
53
- * @returns {Promise<{tavily: string[], parallel: string[]}>}
62
+ * @returns {Promise<{tavily: string[], parallel: string[], brave: string[]}>}
54
63
  */
55
64
  export async function discoverKeys(opts = {}) {
56
65
  const cwd = opts.cwd || process.cwd();
57
66
 
58
67
  // Level 1: explicit
59
- const explicitTavily = [
60
- ...arrayify(opts.tavilyKey),
61
- ...arrayify(opts.tavilyKeys),
62
- ];
63
- const explicitParallel = [
64
- ...arrayify(opts.parallelKey),
65
- ...arrayify(opts.parallelKeys),
66
- ];
68
+ const explicit = {
69
+ tavily: [...arrayify(opts.tavilyKey), ...arrayify(opts.tavilyKeys)],
70
+ parallel: [...arrayify(opts.parallelKey), ...arrayify(opts.parallelKeys)],
71
+ brave: [...arrayify(opts.braveKey), ...arrayify(opts.braveKeys)],
72
+ };
67
73
 
68
74
  // Level 2: process.env
69
- const envTavily = [
70
- ...splitCsv(process.env.TAVILY_API_KEYS),
71
- process.env.TAVILY_API_KEY,
72
- ].filter(Boolean);
73
- const envParallel = [
74
- ...splitCsv(process.env.PARALLEL_API_KEYS),
75
- process.env.PARALLEL_API_KEY,
76
- ].filter(Boolean);
75
+ const env = {
76
+ tavily: readFromObject(process.env, 'TAVILY'),
77
+ parallel: readFromObject(process.env, 'PARALLEL'),
78
+ brave: readFromObject(process.env, 'BRAVE'),
79
+ };
77
80
 
78
81
  // Level 3: .env file
79
- let dotenvTavily = [];
80
- let dotenvParallel = [];
82
+ let dotenv = { tavily: [], parallel: [], brave: [] };
81
83
  if (!opts.skipDotenv) {
82
- const env = await loadDotenv(cwd);
83
- dotenvTavily = [
84
- ...splitCsv(env.TAVILY_API_KEYS),
85
- env.TAVILY_API_KEY,
86
- ].filter(Boolean);
87
- dotenvParallel = [
88
- ...splitCsv(env.PARALLEL_API_KEYS),
89
- env.PARALLEL_API_KEY,
90
- ].filter(Boolean);
84
+ const parsed = await loadDotenv(cwd);
85
+ dotenv = {
86
+ tavily: readFromObject(parsed, 'TAVILY'),
87
+ parallel: readFromObject(parsed, 'PARALLEL'),
88
+ brave: readFromObject(parsed, 'BRAVE'),
89
+ };
91
90
  }
92
91
 
93
- // Level 4: ~/.config/surf/keys.json (only if nothing yet from 1-3)
94
- let cfgTavily = [];
95
- let cfgParallel = [];
96
- const noneSoFarTavily = !explicitTavily.length && !envTavily.length && !dotenvTavily.length;
97
- const noneSoFarParallel = !explicitParallel.length && !envParallel.length && !dotenvParallel.length;
98
- if (!opts.skipConfigFile && (noneSoFarTavily || noneSoFarParallel)) {
99
- try {
100
- const state = await loadState();
101
- if (noneSoFarTavily) cfgTavily = state.tavily.keys || [];
102
- if (noneSoFarParallel) cfgParallel = state.parallel.keys || [];
103
- } catch {}
92
+ // Level 4: ~/.config/surf/keys.json (per-provider, only if 1-3 are empty
93
+ // for that provider)
94
+ const cfg = { tavily: [], parallel: [], brave: [] };
95
+ if (!opts.skipConfigFile) {
96
+ const needCfg = (p) => !explicit[p].length && !env[p].length && !dotenv[p].length;
97
+ if (needCfg('tavily') || needCfg('parallel') || needCfg('brave')) {
98
+ try {
99
+ const state = await loadState();
100
+ if (needCfg('tavily')) cfg.tavily = state.tavily.keys || [];
101
+ if (needCfg('parallel')) cfg.parallel = state.parallel.keys || [];
102
+ if (needCfg('brave')) cfg.brave = state.brave.keys || [];
103
+ } catch {}
104
+ }
104
105
  }
105
106
 
106
107
  return {
107
- tavily: [...new Set([...explicitTavily, ...envTavily, ...dotenvTavily, ...cfgTavily])],
108
- parallel: [...new Set([...explicitParallel, ...envParallel, ...dotenvParallel, ...cfgParallel])],
108
+ tavily: [...new Set([...explicit.tavily, ...env.tavily, ...dotenv.tavily, ...cfg.tavily])],
109
+ parallel: [...new Set([...explicit.parallel, ...env.parallel, ...dotenv.parallel, ...cfg.parallel])],
110
+ brave: [...new Set([...explicit.brave, ...env.brave, ...dotenv.brave, ...cfg.brave])],
109
111
  };
110
112
  }
111
113
 
@@ -114,11 +116,12 @@ export async function discoverKeys(opts = {}) {
114
116
  * without touching ~/.config/surf/keys.json.
115
117
  */
116
118
  export async function buildInMemoryState(opts = {}) {
117
- const { tavily, parallel } = await discoverKeys(opts);
119
+ const { tavily, parallel, brave } = await discoverKeys(opts);
118
120
  return {
119
121
  schema_version: 1,
120
- tavily: { keys: tavily, current: 0, burned: [] },
122
+ tavily: { keys: tavily, current: 0, burned: [] },
121
123
  parallel: { keys: parallel, current: 0, burned: [] },
124
+ brave: { keys: brave, current: 0, burned: [] },
122
125
  last_ok_provider: null,
123
126
  _inMemory: true,
124
127
  };
@@ -63,7 +63,8 @@ export async function search(query, opts = {}) {
63
63
  function buildArgs(query, opts) {
64
64
  return {
65
65
  query,
66
- depth: opts.depth || 'advanced',
66
+ mode: opts.mode, // 'fast' | 'normal' | 'slow' (per-provider mapping)
67
+ depth: opts.depth || (opts.mode ? undefined : 'advanced'),
67
68
  max: opts.max,
68
69
  topic: opts.topic,
69
70
  time: opts.time,
package/src/lib/cost.mjs CHANGED
@@ -1,6 +1,6 @@
1
- // Credit estimation. We model both providers on the same "credit" scale that
2
- // Tavily uses (since that's the only published one). Parallel costs are
3
- // estimated coarsely from documented processor tiers.
1
+ // Credit estimation. We model all providers on a unified "credit" scale
2
+ // (Tavily uses real credits; Parallel uses processor tiers we map; Brave is
3
+ // metered in USD per query, which we model as 1 credit per search).
4
4
 
5
5
  import { clamp, ceilDiv } from './flags.mjs';
6
6
 
@@ -68,10 +68,23 @@ function estimateParallel(op, args) {
68
68
  }
69
69
  }
70
70
 
71
+ // Brave — metered at ~$0.003/query for /web/search; we report 1 credit as
72
+ // a proxy so the cost guard never blocks Brave searches. Operations Brave
73
+ // doesn't support return Infinity so the chain estimator skips them.
74
+ function estimateBrave(op, _args) {
75
+ switch (op) {
76
+ case 'search': return 1;
77
+ default: return Infinity;
78
+ }
79
+ }
80
+
71
81
  export function estimateCreditsForChain(operation, args, chain) {
72
82
  let worst = 0;
73
83
  for (const p of chain) {
74
- const est = p === 'tavily' ? estimateTavily(operation, args) : estimateParallel(operation, args);
84
+ const est = p === 'tavily' ? estimateTavily(operation, args)
85
+ : p === 'parallel' ? estimateParallel(operation, args)
86
+ : p === 'brave' ? estimateBrave(operation, args)
87
+ : 0;
75
88
  if (Number.isFinite(est) && est > worst) worst = est;
76
89
  }
77
90
  return worst;
@@ -137,6 +137,7 @@ export async function ensureKeysSkeleton() {
137
137
  schema_version: 1,
138
138
  tavily: { keys: [], current: 0, burned: [] },
139
139
  parallel: { keys: [], current: 0, burned: [] },
140
+ brave: { keys: [], current: 0, burned: [] },
140
141
  last_ok_provider: null,
141
142
  };
142
143
  await fs.writeFile(file, JSON.stringify(skeleton, null, 2) + '\n');
@@ -0,0 +1,168 @@
1
+ // Brave Search adapter — talks to https://api.search.brave.com/res/v1 with
2
+ // X-Subscription-Token header.
3
+ //
4
+ // Capability:
5
+ // - search: GET /web/search (only operation supported)
6
+ // - extract / crawl / map / research-*: NOT supported (Brave has no equivalents)
7
+ //
8
+ // Auth: header `X-Subscription-Token: <key>` (NOT Bearer; NOT ?apikey=).
9
+ // Rate limits: 50 RPS for /web/search (2 RPS for /summarizer/* — not used here).
10
+ // Pricing as of 2026-05: $5/mo credit + metered (~$0.003/query). Free tier
11
+ // (2,000 queries/mo) was discontinued in Feb 2026.
12
+
13
+ import { compactObject, clamp } from '../flags.mjs';
14
+
15
+ const BASE = process.env.SURF_BRAVE_API_BASE || 'https://api.search.brave.com/res/v1';
16
+ const DEFAULT_TIMEOUT = Number(process.env.SURF_TIMEOUT_MS) || 45000;
17
+
18
+ // Mode → count mapping. Brave doesn't have native fast/slow tiers, so we use
19
+ // `count` (max 20) as the differentiator. fast = fewer results, slow = more.
20
+ const MODE_TO_COUNT = { fast: 5, normal: 10, slow: 20 };
21
+
22
+ export const braveProvider = {
23
+ name: 'brave',
24
+ supports: {
25
+ search: true,
26
+ extract: false,
27
+ crawl: false,
28
+ map: false,
29
+ 'research-start': false,
30
+ 'research-poll': false,
31
+ usage: false,
32
+ },
33
+ search,
34
+ extract: notSupported('extract'),
35
+ crawl: notSupported('crawl'),
36
+ map: notSupported('map'),
37
+ 'research-start': notSupported('research-start'),
38
+ 'research-poll': notSupported('research-poll'),
39
+ usage: notSupported('usage'),
40
+ mapError,
41
+ };
42
+
43
+ function notSupported(op) {
44
+ return async () => {
45
+ throw Object.assign(new Error(`brave does not support '${op}'`), {
46
+ kind: 'not_supported', statusCode: 0,
47
+ });
48
+ };
49
+ }
50
+
51
+ function buildHeaders(key, version) {
52
+ return {
53
+ 'X-Subscription-Token': key,
54
+ 'Accept': 'application/json',
55
+ 'X-Client-Name': `surf-skill/${version || '2.1.0'}`,
56
+ };
57
+ }
58
+
59
+ async function doFetch(path, params, ctx) {
60
+ const url = new URL(`${BASE}${path}`);
61
+ for (const [k, v] of Object.entries(params)) {
62
+ if (v != null && v !== '') url.searchParams.set(k, String(v));
63
+ }
64
+ const timeout = ctx.timeout || DEFAULT_TIMEOUT;
65
+ const ctl = new AbortController();
66
+ const t = setTimeout(() => ctl.abort('timeout'), timeout);
67
+ const t0 = Date.now();
68
+ try {
69
+ const res = await fetch(url, {
70
+ method: 'GET',
71
+ headers: buildHeaders(ctx.key, ctx.version),
72
+ signal: ctl.signal,
73
+ });
74
+ clearTimeout(t);
75
+ const text = await res.text();
76
+ let data;
77
+ try { data = JSON.parse(text); } catch { data = { raw: text }; }
78
+ return { status: res.status, ok: res.ok, data, latency_ms: Date.now() - t0 };
79
+ } catch (e) {
80
+ clearTimeout(t);
81
+ if (e.name === 'AbortError' || /timeout/i.test(e.message)) {
82
+ throw Object.assign(new Error(`Brave request exceeded ${timeout}ms`), { kind: 'network' });
83
+ }
84
+ throw Object.assign(new Error(`Brave network error: ${e.message}`), { kind: 'network' });
85
+ }
86
+ }
87
+
88
+ function mapError(status, body) {
89
+ const msg = (body && (body.error?.message || body.message)) || '';
90
+ if (status === 401) return { kind: 'auth', statusCode: status, message: 'invalid Brave key' };
91
+ if (status === 402) return { kind: 'auth', statusCode: status, message: msg || 'Brave: insufficient credits / billing required' };
92
+ if (status === 403) return { kind: 'auth', statusCode: status, message: msg || 'forbidden (plan/billing)' };
93
+ if (status === 422) return { kind: 'caller_4xx', statusCode: status, message: msg || 'invalid params' };
94
+ if (status === 429) return { kind: 'rate_limit_429', statusCode: status, message: msg || 'Brave rate limit (50 RPS search)' };
95
+ if (status >= 500) return { kind: 'server_5xx', statusCode: status, message: msg || 'Brave server error' };
96
+ if (status >= 400) return { kind: 'caller_4xx', statusCode: status, message: msg || `HTTP ${status}` };
97
+ return { kind: 'caller_4xx', statusCode: status, message: msg || `unexpected HTTP ${status}` };
98
+ }
99
+
100
+ function asError(status, body) {
101
+ const m = mapError(status, body);
102
+ return Object.assign(new Error(`brave ${m.kind} (HTTP ${status}): ${m.message}`), m, { body });
103
+ }
104
+
105
+ function resolveMode(args) {
106
+ if (args.mode === 'fast' || args.mode === 'normal' || args.mode === 'slow') return args.mode;
107
+ // Backward compat with --depth (Tavily-ism):
108
+ if (args.depth === 'advanced') return 'slow';
109
+ if (args.depth === 'fast' || args.depth === 'ultra-fast') return 'fast';
110
+ return 'normal';
111
+ }
112
+
113
+ async function search(args, ctx) {
114
+ const query = args.query;
115
+ if (!query) {
116
+ throw Object.assign(new Error('brave search requires a query'), {
117
+ kind: 'caller_4xx', statusCode: 400,
118
+ });
119
+ }
120
+
121
+ const mode = resolveMode(args);
122
+ // If user explicitly passed --max, honor it (capped at Brave's max=20);
123
+ // otherwise derive from mode.
124
+ const count = args.max
125
+ ? clamp(Number(args.max), 1, 20)
126
+ : (MODE_TO_COUNT[mode] || 10);
127
+
128
+ const params = compactObject({
129
+ q: query,
130
+ count,
131
+ offset: args.offset != null ? clamp(Number(args.offset), 0, 9) : undefined,
132
+ country: args.country,
133
+ search_lang: args.searchLang,
134
+ ui_lang: args.uiLang,
135
+ safesearch: args.safesearch, // 'off' | 'moderate' | 'strict'
136
+ goggles_id: args.goggle, // Brave-only ranking filter
137
+ result_filter: args.resultFilter, // 'web,news,faq,...'
138
+ spellcheck: args.spellcheck === false ? 0 : undefined,
139
+ });
140
+
141
+ const { status, ok, data, latency_ms } = await doFetch('/web/search', params, ctx);
142
+ if (!ok) throw asError(status, data);
143
+
144
+ return {
145
+ provider: 'brave',
146
+ operation: 'search',
147
+ raw: data,
148
+ usage: { credits: 1 }, // ~$0.003/query metered; we report 1 credit as proxy
149
+ latency_ms,
150
+ data: {
151
+ query,
152
+ // /web/search response may include a `summarizer` block when the user's
153
+ // plan + query qualify. We surface the summary text as `answer` for
154
+ // schema parity with Tavily.
155
+ answer: data.summarizer && (data.summarizer.summary || data.summarizer.title),
156
+ results: (data.web && data.web.results || []).map(it => ({
157
+ url: it.url,
158
+ title: it.title,
159
+ // Brave returns rich HTML-ish `description`. Caller usually wants
160
+ // plain-ish text; we pass through as-is.
161
+ content: it.description || '',
162
+ score: undefined, // Brave does not expose a numeric score
163
+ raw_content: undefined, // No raw content in /web/search
164
+ published_date: it.age, // Brave returns a human string ("2 days ago")
165
+ })),
166
+ },
167
+ };
168
+ }
@@ -2,15 +2,20 @@
2
2
 
3
3
  import { tavilyProvider } from './tavily.mjs';
4
4
  import { parallelProvider } from './parallel.mjs';
5
+ import { braveProvider } from './brave.mjs';
5
6
 
6
7
  export const PROVIDERS = {
7
8
  tavily: tavilyProvider,
8
9
  parallel: parallelProvider,
10
+ brave: braveProvider,
9
11
  };
10
12
 
11
13
  // Default fallback chain per operation. Adjust with care: order matters.
14
+ // Brave is search-only (no extract/crawl/map/research equivalents). It joins
15
+ // the search chain as the 3rd option — Tavily/Parallel keep their precedence
16
+ // to preserve hot-path behavior for existing users.
12
17
  export const capabilityMap = {
13
- search: ['tavily', 'parallel'],
18
+ search: ['tavily', 'parallel', 'brave'],
14
19
  extract: ['tavily', 'parallel'],
15
20
  crawl: ['tavily'],
16
21
  map: ['tavily'],
@@ -83,9 +83,16 @@ function wrap(operation, raw, data, latency_ms) {
83
83
  }
84
84
 
85
85
  async function search(args, ctx) {
86
+ // Mode (canonical) → Tavily search_depth. Falls back to --depth (legacy
87
+ // alias) and finally to 'basic'.
88
+ const depth = args.mode === 'fast' ? 'fast'
89
+ : args.mode === 'normal' ? 'basic'
90
+ : args.mode === 'slow' ? 'advanced'
91
+ : (args.depth || 'basic');
92
+
86
93
  const body = compactObject({
87
94
  query: args.query,
88
- search_depth: args.depth || 'basic',
95
+ search_depth: depth,
89
96
  max_results: clamp(Number(args.max) || 5, 1, 20),
90
97
  topic: args.topic,
91
98
  time_range: args.time,
package/src/lib/setup.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // `surf-skill keys add` directly.
3
3
  //
4
4
  // Multi-key: prompts for N keys per provider (Enter to finish that provider).
5
+ // 3 providers: Tavily, Parallel, Brave.
5
6
 
6
7
  import readline from 'node:readline/promises';
7
8
  import { stdin, stdout } from 'node:process';
@@ -13,24 +14,26 @@ const BANNER = `
13
14
  │ (Enter empty to finish a provider; Enter twice in a row to
14
15
  │ skip it entirely).
15
16
 
16
- │ Tavily: https://app.tavily.com (1,000 free credits/mo)
17
+ │ Tavily: https://app.tavily.com (1,000 free credits/mo)
17
18
  │ Parallel: https://platform.parallel.ai
19
+ │ Brave: https://api-dashboard.search.brave.com ($5/mo credit, metered)
18
20
 
19
21
  │ Keys live in ${KEYS_FILE} (chmod 600).
20
22
  └──────────────────────────────────────────────────────────
21
23
  `;
22
24
 
23
25
  const CHEAT_SHEET_TPL = (counts) => `
24
- ✓ Saved ${counts.tav} Tavily key${counts.tav === 1 ? '' : 's'}, ${counts.par} Parallel key${counts.par === 1 ? '' : 's'}.
26
+ ✓ Saved. Now have ${counts.tav} Tavily key${counts.tav === 1 ? '' : 's'}, ${counts.par} Parallel key${counts.par === 1 ? '' : 's'}, ${counts.brv} Brave key${counts.brv === 1 ? '' : 's'}.
25
27
 
26
28
  Try one of:
27
29
  surf-skill search "your query"
28
- surf-skill search "q1" "q2" "q3" # batch (N queries)
30
+ surf-skill search "q1" "q2" "q3" # batch (N queries)
31
+ surf-skill search "x" --provider brave --mode fast
29
32
  surf-skill extract https://example.com
30
33
  surf-skill keys list
31
34
 
32
35
  Add another key later with:
33
- surf-skill keys add --provider <tavily|parallel> <key>
36
+ surf-skill keys add --provider <tavily|parallel|brave> <key>
34
37
 
35
38
  🛠 IMPORTANT — in each project where you'll use surf-skill, run:
36
39
  surf-skill project-config
@@ -72,7 +75,8 @@ export async function runSetup() {
72
75
  if (!stdin.isTTY) {
73
76
  const err = new Error(`'setup' requires a TTY. Use:
74
77
  surf-skill keys add --provider tavily <key>
75
- surf-skill keys add --provider parallel <key>`);
78
+ surf-skill keys add --provider parallel <key>
79
+ surf-skill keys add --provider brave <key>`);
76
80
  err.code = 'NO_TTY';
77
81
  throw err;
78
82
  }
@@ -83,29 +87,39 @@ export async function runSetup() {
83
87
  const rl = readline.createInterface({ input: stdin, output: stdout });
84
88
  let newTav = [];
85
89
  let newPar = [];
90
+ let newBrv = [];
86
91
  try {
87
92
  newTav = await promptKeys(rl, 'Tavily', state.tavily.keys);
88
93
  stdout.write('\n');
89
94
  newPar = await promptKeys(rl, 'Parallel', state.parallel.keys);
95
+ stdout.write('\n');
96
+ newBrv = await promptKeys(rl, 'Brave', state.brave.keys);
90
97
  } finally {
91
98
  rl.close();
92
99
  }
93
100
 
94
- if (!newTav.length && !newPar.length) {
101
+ if (!newTav.length && !newPar.length && !newBrv.length) {
95
102
  stdout.write('\nNo new keys provided. Rerun with: surf-skill setup\n');
96
- return { addedTavily: 0, addedParallel: 0 };
103
+ return { addedTavily: 0, addedParallel: 0, addedBrave: 0 };
97
104
  }
98
105
 
99
106
  for (const k of newTav) state.tavily.keys.push(k);
100
107
  for (const k of newPar) state.parallel.keys.push(k);
108
+ for (const k of newBrv) state.brave.keys.push(k);
101
109
  if (state.tavily.keys.length && state.tavily.current >= state.tavily.keys.length) state.tavily.current = 0;
102
110
  if (state.parallel.keys.length && state.parallel.current >= state.parallel.keys.length) state.parallel.current = 0;
111
+ if (state.brave.keys.length && state.brave.current >= state.brave.keys.length) state.brave.current = 0;
103
112
 
104
113
  await saveStateAtomic(state);
105
114
 
106
115
  stdout.write(CHEAT_SHEET_TPL({
107
116
  tav: state.tavily.keys.length,
108
117
  par: state.parallel.keys.length,
118
+ brv: state.brave.keys.length,
109
119
  }));
110
- return { addedTavily: newTav.length, addedParallel: newPar.length };
120
+ return {
121
+ addedTavily: newTav.length,
122
+ addedParallel: newPar.length,
123
+ addedBrave: newBrv.length,
124
+ };
111
125
  }
package/src/lib/state.mjs CHANGED
@@ -14,7 +14,7 @@ export const LOCK_FILE = join(CONFIG_DIR, '.keys.lock');
14
14
  export const CACHE_DIR = join(homedir(), '.cache', 'surf');
15
15
  export const LEGACY_CACHE_DIR = join(homedir(), '.cache', 'tavily-skill');
16
16
 
17
- export const PROVIDERS = ['tavily', 'parallel'];
17
+ export const PROVIDERS = ['tavily', 'parallel', 'brave'];
18
18
  export const SCHEMA_VERSION = 1;
19
19
 
20
20
  const BURNED_CAP = 50;
@@ -24,12 +24,9 @@ function blankProvider() {
24
24
  }
25
25
 
26
26
  function blankState() {
27
- return {
28
- schema_version: SCHEMA_VERSION,
29
- tavily: blankProvider(),
30
- parallel: blankProvider(),
31
- last_ok_provider: null,
32
- };
27
+ const s = { schema_version: SCHEMA_VERSION, last_ok_provider: null };
28
+ for (const p of PROVIDERS) s[p] = blankProvider();
29
+ return s;
33
30
  }
34
31
 
35
32
  async function ensureConfigDir() {
@@ -115,6 +112,23 @@ export async function migrateLegacy() {
115
112
  }
116
113
  }
117
114
 
115
+ // Normalize a parsed keys.json to the current schema. Crucially, this
116
+ // auto-adds any provider section that's missing from older keys.json files
117
+ // (e.g. v2.0.x users upgrading to v2.1.x get a fresh `brave` section without
118
+ // any manual migration step).
119
+ function normalizeFullState(parsed) {
120
+ const out = {
121
+ schema_version: (parsed && parsed.schema_version) || SCHEMA_VERSION,
122
+ last_ok_provider: parsed && PROVIDERS.includes(parsed.last_ok_provider)
123
+ ? parsed.last_ok_provider
124
+ : null,
125
+ };
126
+ for (const p of PROVIDERS) {
127
+ out[p] = normalizeProvider(parsed && parsed[p]);
128
+ }
129
+ return out;
130
+ }
131
+
118
132
  export async function loadState({ skipMonthlyReset = false } = {}) {
119
133
  await ensureConfigDir();
120
134
  let raw = blankState();
@@ -122,12 +136,7 @@ export async function loadState({ skipMonthlyReset = false } = {}) {
122
136
  try {
123
137
  const txt = await readFile(KEYS_FILE, 'utf8');
124
138
  const parsed = JSON.parse(txt);
125
- raw = {
126
- schema_version: parsed.schema_version || SCHEMA_VERSION,
127
- tavily: normalizeProvider(parsed.tavily),
128
- parallel: normalizeProvider(parsed.parallel),
129
- last_ok_provider: PROVIDERS.includes(parsed.last_ok_provider) ? parsed.last_ok_provider : null,
130
- };
139
+ raw = normalizeFullState(parsed);
131
140
  } catch {
132
141
  raw = blankState();
133
142
  }
@@ -142,12 +151,7 @@ export async function saveStateAtomic(state) {
142
151
  await ensureConfigDir();
143
152
  await acquireLock();
144
153
  try {
145
- const safe = {
146
- schema_version: SCHEMA_VERSION,
147
- tavily: normalizeProvider(state.tavily),
148
- parallel: normalizeProvider(state.parallel),
149
- last_ok_provider: PROVIDERS.includes(state.last_ok_provider) ? state.last_ok_provider : null,
150
- };
154
+ const safe = normalizeFullState(state);
151
155
  const tmp = KEYS_FILE + '.tmp';
152
156
  const payload = JSON.stringify(safe, null, 2);
153
157
  await writeFile(tmp, payload, { mode: 0o600 });