surf-skill 2.0.0 → 2.1.1
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 +113 -0
- package/README.md +58 -22
- package/SKILL.md +25 -10
- package/bin/surf-skill.mjs +12 -3
- package/package.json +3 -2
- package/src/env.mjs +55 -52
- package/src/lib/api/search.mjs +2 -1
- package/src/lib/cost.mjs +17 -4
- package/src/lib/dispatch.mjs +1 -1
- package/src/lib/harness-install.mjs +1 -0
- package/src/lib/providers/brave.mjs +174 -0
- package/src/lib/providers/index.mjs +6 -1
- package/src/lib/providers/tavily.mjs +8 -1
- package/src/lib/setup.mjs +22 -8
- package/src/lib/state.mjs +23 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,118 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.1.1 — Robust key rotation: Brave 422 now burns the key
|
|
4
|
+
|
|
5
|
+
### Bug
|
|
6
|
+
|
|
7
|
+
When a Brave key was malformed (wrong length/charset), the API returns
|
|
8
|
+
HTTP **422** rather than 401. v2.1.0 classified 422 as `caller_4xx`, which
|
|
9
|
+
made dispatch throw without burning the key or trying the next one. That
|
|
10
|
+
violated the cross-provider fallback contract: a single bad-format key
|
|
11
|
+
could short-circuit the chain instead of rotating.
|
|
12
|
+
|
|
13
|
+
### Fix
|
|
14
|
+
|
|
15
|
+
`src/lib/providers/brave.mjs::mapError` now classifies 422 as `auth`
|
|
16
|
+
(burn key, rotate). The trade-off:
|
|
17
|
+
|
|
18
|
+
- **Real malformed token** → burns the key, dispatch tries the next key
|
|
19
|
+
(or next provider if `--provider` not set). The user gets a result.
|
|
20
|
+
- **Genuinely bad query param** → all keys fail with 422; surfaces as
|
|
21
|
+
`AllProvidersExhausted` with a hint, still actionable.
|
|
22
|
+
|
|
23
|
+
### Verified
|
|
24
|
+
|
|
25
|
+
Re-ran the 3 fallback tests with v2.1.1:
|
|
26
|
+
|
|
27
|
+
- T1 same-provider rotation (tavily key #0 bad → key #1 succeeds): ✓
|
|
28
|
+
- T2 cross-provider fallback (tavily/parallel both 401 → brave 200): ✓
|
|
29
|
+
- T3 all keys bad incl. malformed Brave: **now burns Brave key + reports
|
|
30
|
+
AllProvidersExhausted** (instead of throwing on the 422)
|
|
31
|
+
|
|
32
|
+
### Also fixed
|
|
33
|
+
|
|
34
|
+
- `src/lib/dispatch.mjs::VERSION` was stuck at `1.0.0` since the initial
|
|
35
|
+
release; bumped to `2.1.1` so the `X-Client-Name` header surfaces
|
|
36
|
+
the correct CLI version to providers.
|
|
37
|
+
- `SKILL.md::metadata.version` was missed in the v2.1.0 bump (still
|
|
38
|
+
showed `2.0.0`); now `2.1.1`.
|
|
39
|
+
|
|
40
|
+
No breaking changes.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## v2.1.0 — Brave Search as 3rd provider + `--mode` flag
|
|
45
|
+
|
|
46
|
+
### What's new
|
|
47
|
+
|
|
48
|
+
- **Brave Search added as 3rd provider** (`--provider brave`). Brave runs
|
|
49
|
+
its own index (independent from Google/Bing) and currently offers $5/mo
|
|
50
|
+
in API credit + metered usage (~$0.003/query) after the free tier was
|
|
51
|
+
retired in Feb 2026. Search only — Brave has no extract/crawl/map/
|
|
52
|
+
research equivalents, so the capability map keeps those Tavily-only.
|
|
53
|
+
- **New `--mode <fast|normal|slow>` flag** for `search`. Each provider
|
|
54
|
+
translates the mode to its native tier:
|
|
55
|
+
|
|
56
|
+
| Mode | Tavily | Parallel | Brave |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `fast` | `search_depth=fast` | (ignored) | `count=5` |
|
|
59
|
+
| `normal` | `search_depth=basic` | `/v1/search` | `count=10` |
|
|
60
|
+
| `slow` | `search_depth=advanced` | (ignored) | `count=20` |
|
|
61
|
+
|
|
62
|
+
`--depth basic|advanced` continues to work as a legacy alias for Tavily.
|
|
63
|
+
|
|
64
|
+
- **Library API gets `opts.mode`, `opts.braveKey`, `opts.braveKeys`**:
|
|
65
|
+
```js
|
|
66
|
+
await search('claude api', { mode: 'fast', braveKey: 'BS...' });
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- **`BRAVE_API_KEY` / `BRAVE_API_KEYS` env vars** now part of the discovery
|
|
70
|
+
hierarchy (env > .env > ~/.config/surf/keys.json).
|
|
71
|
+
|
|
72
|
+
- **Setup wizard** now prompts for Brave keys after Tavily + Parallel.
|
|
73
|
+
|
|
74
|
+
- **State migration is transparent**: existing `~/.config/surf/keys.json`
|
|
75
|
+
files from v2.0.x get a `brave` section added automatically on next
|
|
76
|
+
`loadState()` — no manual upgrade step needed.
|
|
77
|
+
|
|
78
|
+
### Default chain order
|
|
79
|
+
|
|
80
|
+
`search` chain is now `[tavily, parallel, brave]`. Existing users see no
|
|
81
|
+
behavior change (Tavily still tried first); Brave is the 3rd fallback.
|
|
82
|
+
`last_ok_provider` still wins.
|
|
83
|
+
|
|
84
|
+
### Breaking changes
|
|
85
|
+
|
|
86
|
+
None. CLI and library APIs are backward compatible.
|
|
87
|
+
|
|
88
|
+
### Files added
|
|
89
|
+
|
|
90
|
+
- `src/lib/providers/brave.mjs` — adapter, `mapError()`, `/web/search`
|
|
91
|
+
with mode → count translation.
|
|
92
|
+
|
|
93
|
+
### Files modified
|
|
94
|
+
|
|
95
|
+
- `src/lib/providers/index.mjs` — register brave + add to capabilityMap.search
|
|
96
|
+
- `src/lib/state.mjs` — `PROVIDERS = ['tavily', 'parallel', 'brave']` +
|
|
97
|
+
`normalizeFullState()` for graceful schema migration
|
|
98
|
+
- `src/env.mjs` — `discoverKeys()` returns `{ tavily, parallel, brave }`
|
|
99
|
+
- `src/lib/cost.mjs` — `estimateBrave()` returns 1 credit/search
|
|
100
|
+
- `src/lib/setup.mjs` — 3-provider wizard
|
|
101
|
+
- `src/lib/providers/tavily.mjs` — mode → search_depth resolution
|
|
102
|
+
- `src/lib/api/search.mjs` — library opts.mode
|
|
103
|
+
- `bin/surf-skill.mjs` — HELP + `--mode` flag wiring
|
|
104
|
+
- `src/lib/harness-install.mjs` — skeleton with brave section
|
|
105
|
+
- `package.json`, `SKILL.md`, `README.md` — bump 2.0.0 → 2.1.0, doc updates
|
|
106
|
+
|
|
107
|
+
### Fora de escopo
|
|
108
|
+
|
|
109
|
+
- Brave `/summarizer/search` endpoint — defer to v2.2 (different rate
|
|
110
|
+
limit, response shape adds `data.answer`).
|
|
111
|
+
- Brave Goggles support (`--goggle <id>`) — defer.
|
|
112
|
+
- News / Images / Videos / Local / Spellcheck endpoints — defer.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
3
116
|
## v2.0.0 — npm package, cross-OS install, library mode
|
|
4
117
|
|
|
5
118
|
### What's new
|
package/README.md
CHANGED
|
@@ -1,24 +1,40 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="logo.png" alt="surf-skill logo" width="160" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 ┤
|
|
12
|
-
crawl ──┼──▶ surf
|
|
13
|
-
map ───┤
|
|
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** |
|
|
20
|
-
| **
|
|
21
|
-
| **
|
|
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>
|
|
221
|
-
--
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
--
|
|
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
|
@@ -4,7 +4,7 @@ description: Web search, content extraction, site crawl, URL mapping, and deep r
|
|
|
4
4
|
license: MIT
|
|
5
5
|
allowed-tools: bash
|
|
6
6
|
metadata:
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.1.1"
|
|
8
8
|
requires: "node>=18; install via `npm i -g surf-skill`; keys via 'surf-skill setup' (multi-key wizard); per-project bash timeout via 'surf-skill project-config'"
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -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
|
package/bin/surf-skill.mjs
CHANGED
|
@@ -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.
|
|
18
|
+
const VERSION = '2.1.1';
|
|
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>
|
|
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
|
-
|
|
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.
|
|
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.1",
|
|
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
|
|
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
|
|
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]
|
|
47
|
-
* @param {string[]} [opts.
|
|
48
|
-
* @param {string|string[]} [opts.
|
|
49
|
-
* @param {
|
|
50
|
-
* @param {boolean} [opts.
|
|
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
|
|
60
|
-
...arrayify(opts.tavilyKey),
|
|
61
|
-
...arrayify(opts.
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
process.env
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
80
|
-
let dotenvParallel = [];
|
|
82
|
+
let dotenv = { tavily: [], parallel: [], brave: [] };
|
|
81
83
|
if (!opts.skipDotenv) {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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:
|
|
108
|
-
parallel: [...new Set([...
|
|
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:
|
|
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
|
};
|
package/src/lib/api/search.mjs
CHANGED
|
@@ -63,7 +63,8 @@ export async function search(query, opts = {}) {
|
|
|
63
63
|
function buildArgs(query, opts) {
|
|
64
64
|
return {
|
|
65
65
|
query,
|
|
66
|
-
|
|
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
|
|
2
|
-
// Tavily uses
|
|
3
|
-
//
|
|
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'
|
|
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;
|
package/src/lib/dispatch.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { sleep } from './flags.mjs';
|
|
|
13
13
|
import { progress } from './progress.mjs';
|
|
14
14
|
|
|
15
15
|
const CACHEABLE = new Set(['search', 'extract', 'map']);
|
|
16
|
-
const VERSION = '1.
|
|
16
|
+
const VERSION = '2.1.1';
|
|
17
17
|
|
|
18
18
|
// Detect the agent harness's bash timeout from env vars. The number is the
|
|
19
19
|
// total time (ms) the harness will allow our process to live before SIGTERM.
|
|
@@ -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,174 @@
|
|
|
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
|
+
// Brave returns 422 for several reasons: malformed token (length/charset
|
|
94
|
+
// wrong, fails BEFORE auth check), OR bad query params. We classify 422 as
|
|
95
|
+
// `auth` so the key gets burned and dispatch rotates. The trade-off: a
|
|
96
|
+
// genuinely-bad query param will fail across ALL keys and surface as
|
|
97
|
+
// AllProvidersExhausted, still actionable. A malformed token is the
|
|
98
|
+
// dominant cause in practice (real tokens hit 401 instead).
|
|
99
|
+
if (status === 422) return { kind: 'auth', statusCode: status, message: msg || 'Brave: malformed token or invalid params (key rotation will retry; if all keys fail, you likely have a bad query)' };
|
|
100
|
+
if (status === 429) return { kind: 'rate_limit_429', statusCode: status, message: msg || 'Brave rate limit (50 RPS search)' };
|
|
101
|
+
if (status >= 500) return { kind: 'server_5xx', statusCode: status, message: msg || 'Brave server error' };
|
|
102
|
+
if (status >= 400) return { kind: 'caller_4xx', statusCode: status, message: msg || `HTTP ${status}` };
|
|
103
|
+
return { kind: 'caller_4xx', statusCode: status, message: msg || `unexpected HTTP ${status}` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function asError(status, body) {
|
|
107
|
+
const m = mapError(status, body);
|
|
108
|
+
return Object.assign(new Error(`brave ${m.kind} (HTTP ${status}): ${m.message}`), m, { body });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolveMode(args) {
|
|
112
|
+
if (args.mode === 'fast' || args.mode === 'normal' || args.mode === 'slow') return args.mode;
|
|
113
|
+
// Backward compat with --depth (Tavily-ism):
|
|
114
|
+
if (args.depth === 'advanced') return 'slow';
|
|
115
|
+
if (args.depth === 'fast' || args.depth === 'ultra-fast') return 'fast';
|
|
116
|
+
return 'normal';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function search(args, ctx) {
|
|
120
|
+
const query = args.query;
|
|
121
|
+
if (!query) {
|
|
122
|
+
throw Object.assign(new Error('brave search requires a query'), {
|
|
123
|
+
kind: 'caller_4xx', statusCode: 400,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const mode = resolveMode(args);
|
|
128
|
+
// If user explicitly passed --max, honor it (capped at Brave's max=20);
|
|
129
|
+
// otherwise derive from mode.
|
|
130
|
+
const count = args.max
|
|
131
|
+
? clamp(Number(args.max), 1, 20)
|
|
132
|
+
: (MODE_TO_COUNT[mode] || 10);
|
|
133
|
+
|
|
134
|
+
const params = compactObject({
|
|
135
|
+
q: query,
|
|
136
|
+
count,
|
|
137
|
+
offset: args.offset != null ? clamp(Number(args.offset), 0, 9) : undefined,
|
|
138
|
+
country: args.country,
|
|
139
|
+
search_lang: args.searchLang,
|
|
140
|
+
ui_lang: args.uiLang,
|
|
141
|
+
safesearch: args.safesearch, // 'off' | 'moderate' | 'strict'
|
|
142
|
+
goggles_id: args.goggle, // Brave-only ranking filter
|
|
143
|
+
result_filter: args.resultFilter, // 'web,news,faq,...'
|
|
144
|
+
spellcheck: args.spellcheck === false ? 0 : undefined,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const { status, ok, data, latency_ms } = await doFetch('/web/search', params, ctx);
|
|
148
|
+
if (!ok) throw asError(status, data);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
provider: 'brave',
|
|
152
|
+
operation: 'search',
|
|
153
|
+
raw: data,
|
|
154
|
+
usage: { credits: 1 }, // ~$0.003/query metered; we report 1 credit as proxy
|
|
155
|
+
latency_ms,
|
|
156
|
+
data: {
|
|
157
|
+
query,
|
|
158
|
+
// /web/search response may include a `summarizer` block when the user's
|
|
159
|
+
// plan + query qualify. We surface the summary text as `answer` for
|
|
160
|
+
// schema parity with Tavily.
|
|
161
|
+
answer: data.summarizer && (data.summarizer.summary || data.summarizer.title),
|
|
162
|
+
results: (data.web && data.web.results || []).map(it => ({
|
|
163
|
+
url: it.url,
|
|
164
|
+
title: it.title,
|
|
165
|
+
// Brave returns rich HTML-ish `description`. Caller usually wants
|
|
166
|
+
// plain-ish text; we pass through as-is.
|
|
167
|
+
content: it.description || '',
|
|
168
|
+
score: undefined, // Brave does not expose a numeric score
|
|
169
|
+
raw_content: undefined, // No raw content in /web/search
|
|
170
|
+
published_date: it.age, // Brave returns a human string ("2 days ago")
|
|
171
|
+
})),
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -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:
|
|
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
|
|
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"
|
|
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 {
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 });
|