surf-skill 2.0.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.
@@ -0,0 +1,32 @@
1
+ // Provider registry: capability map + factory.
2
+
3
+ import { tavilyProvider } from './tavily.mjs';
4
+ import { parallelProvider } from './parallel.mjs';
5
+
6
+ export const PROVIDERS = {
7
+ tavily: tavilyProvider,
8
+ parallel: parallelProvider,
9
+ };
10
+
11
+ // Default fallback chain per operation. Adjust with care: order matters.
12
+ export const capabilityMap = {
13
+ search: ['tavily', 'parallel'],
14
+ extract: ['tavily', 'parallel'],
15
+ crawl: ['tavily'],
16
+ map: ['tavily'],
17
+ 'research-start': ['parallel', 'tavily'],
18
+ research: ['parallel', 'tavily'],
19
+ 'research-poll': ['BY_REQUEST_ID'],
20
+ usage: ['BY_PROVIDER'],
21
+ };
22
+
23
+ export function getProvider(name) {
24
+ return PROVIDERS[name];
25
+ }
26
+
27
+ export function providerFromRequestId(requestId) {
28
+ if (typeof requestId !== 'string') return null;
29
+ if (requestId.startsWith('tvly:')) return { provider: 'tavily', providerRunId: requestId.slice(5) };
30
+ if (requestId.startsWith('pllx:')) return { provider: 'parallel', providerRunId: requestId.slice(5) };
31
+ return null;
32
+ }
@@ -0,0 +1,270 @@
1
+ // Parallel AI adapter — talks to https://api.parallel.ai with x-api-key header.
2
+ // Capability notes:
3
+ // - search: POST /v1/search
4
+ // - extract: POST /v1beta/extract (beta endpoint)
5
+ // - crawl: NOT supported
6
+ // - map: NOT supported
7
+ // - research-start: POST /v1/tasks/runs (async; returns run_id)
8
+ // - research-poll: GET /v1/tasks/runs/{id} + GET /v1/tasks/runs/{id}/result
9
+ // - usage: NOT documented publicly
10
+ // Auth: header `x-api-key: <key>` (NOT Bearer).
11
+ // Error body: { "type":"error", "error":{ "ref_id":"...", "message":"...", "detail":{} } }
12
+
13
+ import { clamp, compactObject, flat, splitList } from '../flags.mjs';
14
+
15
+ const BASE = process.env.SURF_PARALLEL_API_BASE || 'https://api.parallel.ai';
16
+ const DEFAULT_TIMEOUT = Number(process.env.SURF_TIMEOUT_MS) || 45000;
17
+
18
+ const DEPTH_TO_PROCESSOR = {
19
+ 'ultra-fast': 'lite',
20
+ 'fast': 'lite',
21
+ 'basic': 'lite',
22
+ 'advanced': 'base',
23
+ };
24
+
25
+ const RESEARCH_MODEL_TO_PROCESSOR = {
26
+ mini: 'lite',
27
+ auto: 'base',
28
+ pro: 'pro',
29
+ ultra: 'ultra',
30
+ };
31
+
32
+ export const parallelProvider = {
33
+ name: 'parallel',
34
+ supports: {
35
+ search: true,
36
+ extract: true,
37
+ crawl: false,
38
+ map: false,
39
+ 'research-start': true,
40
+ 'research-poll': true,
41
+ usage: false,
42
+ },
43
+ search,
44
+ extract,
45
+ crawl: notSupported('crawl'),
46
+ map: notSupported('map'),
47
+ 'research-start': researchStart,
48
+ 'research-poll': researchPoll,
49
+ usage: notSupported('usage'),
50
+ mapError,
51
+ };
52
+
53
+ function notSupported(op) {
54
+ return async () => {
55
+ throw Object.assign(new Error(`parallel does not support '${op}'`), {
56
+ kind: 'not_supported', statusCode: 0,
57
+ });
58
+ };
59
+ }
60
+
61
+ function buildHeaders(key, version) {
62
+ return {
63
+ 'x-api-key': key,
64
+ 'Content-Type': 'application/json',
65
+ 'X-Client-Name': `surf-skill/${version || '2.0.0'}`,
66
+ };
67
+ }
68
+
69
+ async function doFetch(path, body, ctx, opts = {}) {
70
+ const method = opts.method || 'POST';
71
+ const timeout = opts.timeoutMs || ctx.timeout || DEFAULT_TIMEOUT;
72
+ const ctl = new AbortController();
73
+ const t = setTimeout(() => ctl.abort('timeout'), timeout);
74
+ const t0 = Date.now();
75
+ try {
76
+ const res = await fetch(`${BASE}${path}`, {
77
+ method,
78
+ headers: buildHeaders(ctx.key, ctx.version),
79
+ body: method === 'POST' ? JSON.stringify(body) : undefined,
80
+ signal: ctl.signal,
81
+ });
82
+ clearTimeout(t);
83
+ const text = await res.text();
84
+ let data;
85
+ try { data = JSON.parse(text); } catch { data = { raw: text }; }
86
+ return { status: res.status, ok: res.ok, data, latency_ms: Date.now() - t0 };
87
+ } catch (e) {
88
+ clearTimeout(t);
89
+ if (e.name === 'AbortError' || /timeout/i.test(e.message)) {
90
+ throw Object.assign(new Error(`Parallel request exceeded ${timeout}ms`), { kind: 'network' });
91
+ }
92
+ throw Object.assign(new Error(`Parallel network error: ${e.message}`), { kind: 'network' });
93
+ }
94
+ }
95
+
96
+ function extractMessage(body) {
97
+ if (!body) return '';
98
+ if (body.error) {
99
+ if (typeof body.error === 'string') return body.error;
100
+ const parts = [];
101
+ if (body.error.message) parts.push(body.error.message);
102
+ // Parallel returns detailed validation errors at body.error.detail.errors[]
103
+ // with shape { type, loc: ["body","field"], msg, input }. Surface them so
104
+ // schema mismatches are debuggable without --raw-json.
105
+ const errs = body.error.detail && body.error.detail.errors;
106
+ if (Array.isArray(errs) && errs.length) {
107
+ for (const e of errs) {
108
+ const loc = Array.isArray(e.loc) ? e.loc.join('.') : '';
109
+ parts.push(`${loc}: ${e.msg}`);
110
+ }
111
+ } else if (body.error.detail) {
112
+ parts.push(flat(body.error.detail));
113
+ }
114
+ return parts.filter(Boolean).join(' — ');
115
+ }
116
+ return flat(body.message) || flat(body.detail) || '';
117
+ }
118
+
119
+ function mapError(status, body) {
120
+ const msg = extractMessage(body);
121
+ if (status === 401) return { kind: 'auth', statusCode: status, message: 'invalid Parallel key' };
122
+ if (status === 402) return { kind: 'auth', statusCode: status, message: 'Parallel: insufficient credits' };
123
+ if (status === 403) {
124
+ // 403 may be auth OR "invalid processor" depending on body. The latter is a
125
+ // caller error — switching keys won't help.
126
+ if (/processor/i.test(msg)) return { kind: 'caller_4xx', statusCode: status, message: msg };
127
+ return { kind: 'auth', statusCode: status, message: msg || 'forbidden' };
128
+ }
129
+ if (status === 429) return { kind: 'rate_limit_429', statusCode: status, message: msg || 'Parallel rate limit' };
130
+ if (status >= 500) return { kind: 'server_5xx', statusCode: status, message: msg || 'Parallel server error' };
131
+ if (status >= 400) return { kind: 'caller_4xx', statusCode: status, message: msg || `HTTP ${status}` };
132
+ return { kind: 'caller_4xx', statusCode: status, message: msg || `unexpected HTTP ${status}` };
133
+ }
134
+
135
+ function asError(status, body) {
136
+ const m = mapError(status, body);
137
+ return Object.assign(new Error(`parallel ${m.kind} (HTTP ${status}): ${m.message}`), m, { body });
138
+ }
139
+
140
+ function wrap(operation, raw, data, latency_ms) {
141
+ return {
142
+ provider: 'parallel',
143
+ operation,
144
+ raw,
145
+ usage: { credits: raw && raw.usage && (raw.usage.credits ?? raw.usage.total_credits) },
146
+ latency_ms,
147
+ data,
148
+ };
149
+ }
150
+
151
+ async function search(args, ctx) {
152
+ const queries = args.searchQueries
153
+ ? (Array.isArray(args.searchQueries) ? args.searchQueries : splitList(args.searchQueries))
154
+ : (args.query ? [args.query] : []);
155
+ if (!queries.length) throw Object.assign(new Error('search requires query or --queries'), { kind: 'caller_4xx', statusCode: 400 });
156
+
157
+ // Parallel POST /v1/search currently accepts ONLY { objective, search_queries }.
158
+ // Any other field (processor, max_results, source_policy, ...) is rejected
159
+ // with "Extra inputs are not permitted". Tavily-only knobs like --depth,
160
+ // --max, --domains are silently ignored on this provider — that's expected.
161
+ // Verified against api.parallel.ai 2026-05-20; if Parallel expands the
162
+ // schema later, add fields here.
163
+ const body = {
164
+ objective: args.objective || args.query || queries[0],
165
+ search_queries: queries,
166
+ };
167
+
168
+ const { status, ok, data, latency_ms } = await doFetch('/v1/search', body, ctx);
169
+ if (!ok) throw asError(status, data);
170
+
171
+ return wrap('search', data, {
172
+ query: args.query || queries.join(' | '),
173
+ answer: undefined,
174
+ results: (data.results || []).map(it => ({
175
+ url: it.url,
176
+ title: it.title,
177
+ content: Array.isArray(it.excerpts) ? it.excerpts.join('\n\n') : (it.excerpts || it.snippet || ''),
178
+ score: undefined,
179
+ raw_content: it.full_content,
180
+ published_date: it.publish_date,
181
+ })),
182
+ }, latency_ms);
183
+ }
184
+
185
+ async function extract(args, ctx) {
186
+ if (!Array.isArray(args.urls) || args.urls.length === 0) {
187
+ throw Object.assign(new Error('extract requires at least 1 URL'), { kind: 'caller_4xx', statusCode: 400 });
188
+ }
189
+ const body = compactObject({
190
+ urls: args.urls,
191
+ objective: args.query,
192
+ excerpts: args.depth !== 'advanced',
193
+ full_content: args.depth === 'advanced',
194
+ });
195
+ const { status, ok, data, latency_ms } = await doFetch('/v1beta/extract', body, ctx);
196
+ if (!ok) throw asError(status, data);
197
+ return wrap('extract', data, {
198
+ results: (data.results || []).map(it => ({
199
+ url: it.url,
200
+ raw_content: it.full_content || (Array.isArray(it.excerpts) ? it.excerpts.join('\n\n') : ''),
201
+ title: it.title,
202
+ images: undefined,
203
+ })),
204
+ failed: (data.errors || []).map(e => ({
205
+ url: e.url || (typeof e === 'string' ? e : ''),
206
+ reason: e.message || e.error || 'unknown',
207
+ })),
208
+ }, latency_ms);
209
+ }
210
+
211
+ async function researchStart(args, ctx) {
212
+ const processor = args.processor || RESEARCH_MODEL_TO_PROCESSOR[args.model || 'auto'] || 'base';
213
+ const body = compactObject({
214
+ input: args.input,
215
+ processor,
216
+ task_spec: args.outputSchema ? { output_schema: args.outputSchema } : undefined,
217
+ });
218
+ const { status, ok, data, latency_ms } = await doFetch('/v1/tasks/runs', body, ctx, { timeoutMs: 30000 });
219
+ if (!ok) throw asError(status, data);
220
+ return wrap('research-start', data, {
221
+ request_id: `pllx:${data.run_id}`,
222
+ provider_run_id: data.run_id,
223
+ status: data.status || 'queued',
224
+ model: processor,
225
+ }, latency_ms);
226
+ }
227
+
228
+ async function researchPoll(args, ctx) {
229
+ const id = args.providerRunId;
230
+ // 1) status check
231
+ const head = await doFetch(`/v1/tasks/runs/${id}`, null, ctx, { method: 'GET', timeoutMs: 15000 });
232
+ if (!head.ok) throw asError(head.status, head.data);
233
+
234
+ const headData = head.data;
235
+ const status = String(headData.status || '').toLowerCase();
236
+ const isCompleted = status === 'completed' || status === 'success';
237
+ const isFailed = status === 'failed' || status === 'errored' || status === 'error';
238
+
239
+ // Map Parallel statuses to normalized vocabulary.
240
+ let normStatus = status;
241
+ if (isCompleted) normStatus = 'completed';
242
+ else if (isFailed) normStatus = 'failed';
243
+ else if (status === 'queued' || status === 'pending') normStatus = 'pending';
244
+ else normStatus = 'running';
245
+
246
+ let content, sources, errorMsg;
247
+ if (isCompleted) {
248
+ const res = await doFetch(`/v1/tasks/runs/${id}/result`, null, ctx, { method: 'GET', timeoutMs: 20000 });
249
+ if (!res.ok) throw asError(res.status, res.data);
250
+ const r = res.data || {};
251
+ const output = r.output || r.result || {};
252
+ content = typeof output === 'string' ? output : (output.content || output.text || JSON.stringify(output));
253
+ sources = (output.basis || r.basis || []).map(s => ({
254
+ url: s.url || s.source_url || '',
255
+ title: s.title,
256
+ })).filter(s => s.url);
257
+ } else if (isFailed) {
258
+ errorMsg = headData.error || extractMessage(headData) || 'task failed';
259
+ }
260
+
261
+ return wrap('research-poll', headData, {
262
+ request_id: `pllx:${id}`,
263
+ provider_run_id: id,
264
+ status: normStatus,
265
+ model: headData.processor,
266
+ content,
267
+ sources,
268
+ error: errorMsg,
269
+ }, head.latency_ms);
270
+ }
@@ -0,0 +1,245 @@
1
+ // Tavily adapter — talks to https://api.tavily.com with Authorization: Bearer.
2
+
3
+ import { clamp, splitList, compactObject, flat } from '../flags.mjs';
4
+
5
+ const BASE = process.env.SURF_TAVILY_API_BASE || process.env.TAVILY_API_BASE || 'https://api.tavily.com';
6
+ const DEFAULT_TIMEOUT = Number(process.env.SURF_TIMEOUT_MS || process.env.TAVILY_TIMEOUT_MS) || 45000;
7
+
8
+ export const tavilyProvider = {
9
+ name: 'tavily',
10
+ supports: {
11
+ search: true,
12
+ extract: true,
13
+ crawl: true,
14
+ map: true,
15
+ 'research-start': true,
16
+ 'research-poll': true,
17
+ usage: true,
18
+ },
19
+ search,
20
+ extract,
21
+ crawl,
22
+ map,
23
+ 'research-start': researchStart,
24
+ 'research-poll': researchPoll,
25
+ usage,
26
+ mapError,
27
+ };
28
+
29
+ function buildHeaders(key, version) {
30
+ return {
31
+ 'Authorization': `Bearer ${key}`,
32
+ 'Content-Type': 'application/json',
33
+ 'X-Client-Name': `surf-skill/${version || '2.0.0'}`,
34
+ };
35
+ }
36
+
37
+ async function doFetch(path, body, ctx, opts = {}) {
38
+ const method = opts.method || 'POST';
39
+ const timeout = opts.timeoutMs || ctx.timeout || DEFAULT_TIMEOUT;
40
+ const ctl = new AbortController();
41
+ const t = setTimeout(() => ctl.abort('timeout'), timeout);
42
+ const t0 = Date.now();
43
+ try {
44
+ const res = await fetch(`${BASE}${path}`, {
45
+ method,
46
+ headers: buildHeaders(ctx.key, ctx.version),
47
+ body: method === 'POST' ? JSON.stringify(body) : undefined,
48
+ signal: ctl.signal,
49
+ });
50
+ clearTimeout(t);
51
+ const text = await res.text();
52
+ let data;
53
+ try { data = JSON.parse(text); } catch { data = { raw: text }; }
54
+ return { status: res.status, ok: res.ok, data, latency_ms: Date.now() - t0 };
55
+ } catch (e) {
56
+ clearTimeout(t);
57
+ if (e.name === 'AbortError' || /timeout/i.test(e.message)) {
58
+ throw Object.assign(new Error(`Tavily request exceeded ${timeout}ms`), { kind: 'network' });
59
+ }
60
+ throw Object.assign(new Error(`Tavily network error: ${e.message}`), { kind: 'network' });
61
+ }
62
+ }
63
+
64
+ function mapError(status, body) {
65
+ if (status === 401) return { kind: 'auth', statusCode: status, message: 'invalid Tavily key' };
66
+ if (status === 403) return { kind: 'auth', statusCode: status, message: 'forbidden Tavily key' };
67
+ if (status === 432 || status === 433) return { kind: 'auth', statusCode: status, message: 'Tavily plan/quota limit hit' };
68
+ if (status === 429) return { kind: 'rate_limit_429', statusCode: status, message: 'Tavily rate limit' };
69
+ if (status >= 500) return { kind: 'server_5xx', statusCode: status, message: 'Tavily server error' };
70
+ if (status >= 400) return { kind: 'caller_4xx', statusCode: status, message: flat(body && (body.error || body.detail || body.message)) || `HTTP ${status}` };
71
+ return { kind: 'caller_4xx', statusCode: status, message: `unexpected HTTP ${status}` };
72
+ }
73
+
74
+ function wrap(operation, raw, data, latency_ms) {
75
+ return {
76
+ provider: 'tavily',
77
+ operation,
78
+ raw,
79
+ usage: { credits: raw && raw.usage && raw.usage.credits },
80
+ latency_ms,
81
+ data,
82
+ };
83
+ }
84
+
85
+ async function search(args, ctx) {
86
+ const body = compactObject({
87
+ query: args.query,
88
+ search_depth: args.depth || 'basic',
89
+ max_results: clamp(Number(args.max) || 5, 1, 20),
90
+ topic: args.topic,
91
+ time_range: args.time,
92
+ start_date: args.startDate,
93
+ end_date: args.endDate,
94
+ include_domains: splitList(args.domains),
95
+ exclude_domains: splitList(args.excludeDomains),
96
+ country: args.country,
97
+ include_answer: args.answer === true ? 'basic' : args.answer,
98
+ include_raw_content: args.raw === true ? 'markdown' : args.raw,
99
+ include_images: !!args.images,
100
+ include_image_descriptions: !!args.imageDesc,
101
+ include_favicon: !!args.favicon,
102
+ auto_parameters: !!args.auto,
103
+ exact_match: !!args.exactMatch,
104
+ include_usage: true,
105
+ });
106
+ const { status, ok, data, latency_ms } = await doFetch('/search', body, ctx);
107
+ if (!ok) throw asError(status, data);
108
+ return wrap('search', data, {
109
+ query: data.query || args.query,
110
+ answer: data.answer,
111
+ results: (data.results || []).map(it => ({
112
+ url: it.url,
113
+ title: it.title,
114
+ content: it.content || '',
115
+ score: it.score,
116
+ raw_content: it.raw_content,
117
+ published_date: it.published_date,
118
+ })),
119
+ }, latency_ms);
120
+ }
121
+
122
+ async function extract(args, ctx) {
123
+ const body = compactObject({
124
+ urls: args.urls,
125
+ extract_depth: args.depth || 'basic',
126
+ format: args.format || 'markdown',
127
+ include_images: !!args.images,
128
+ include_favicon: !!args.favicon,
129
+ query: args.query,
130
+ chunks_per_source: args.chunks ? Number(args.chunks) : undefined,
131
+ timeout: args.extractTimeout ? Number(args.extractTimeout) : undefined,
132
+ include_usage: true,
133
+ });
134
+ const { status, ok, data, latency_ms } = await doFetch('/extract', body, ctx);
135
+ if (!ok) throw asError(status, data);
136
+ return wrap('extract', data, {
137
+ results: (data.results || []).map(it => ({
138
+ url: it.url,
139
+ raw_content: it.raw_content || '',
140
+ title: it.title,
141
+ images: it.images,
142
+ })),
143
+ failed: (data.failed_results || []).map(f => ({
144
+ url: f.url || (typeof f === 'string' ? f : ''),
145
+ reason: f.error || 'unknown',
146
+ })),
147
+ }, latency_ms);
148
+ }
149
+
150
+ async function crawl(args, ctx) {
151
+ const body = compactObject({
152
+ url: args.url,
153
+ max_depth: clamp(Number(args.maxDepth) || 1, 1, 5),
154
+ max_breadth: clamp(Number(args.maxBreadth) || 20, 1, 500),
155
+ limit: clamp(Number(args.limit) || 50, 1, 200),
156
+ instructions: args.instructions,
157
+ select_paths: splitList(args.selectPaths),
158
+ select_domains: splitList(args.selectDomains),
159
+ exclude_paths: splitList(args.excludePaths),
160
+ exclude_domains: splitList(args.excludeDomains),
161
+ allow_external: !!args.allowExternal,
162
+ include_images: !!args.images,
163
+ categories: splitList(args.categories),
164
+ extract_depth: args.extractDepth || 'basic',
165
+ format: args.format || 'markdown',
166
+ query: args.query,
167
+ chunks_per_source: args.chunks ? Number(args.chunks) : undefined,
168
+ timeout: args.timeout ? Number(args.timeout) : undefined,
169
+ include_usage: true,
170
+ });
171
+ const { status, ok, data, latency_ms } = await doFetch('/crawl', body, ctx, { timeoutMs: 50000 });
172
+ if (!ok) throw asError(status, data);
173
+ return wrap('crawl', data, {
174
+ base_url: data.base_url || args.url,
175
+ results: (data.results || []).map(it => typeof it === 'string'
176
+ ? { url: it }
177
+ : { url: it.url, raw_content: it.raw_content }),
178
+ }, latency_ms);
179
+ }
180
+
181
+ async function map(args, ctx) {
182
+ const body = compactObject({
183
+ url: args.url,
184
+ max_depth: clamp(Number(args.maxDepth) || 1, 1, 5),
185
+ max_breadth: clamp(Number(args.maxBreadth) || 20, 1, 500),
186
+ limit: clamp(Number(args.limit) || 50, 1, 500),
187
+ instructions: args.instructions,
188
+ select_paths: splitList(args.selectPaths),
189
+ select_domains: splitList(args.selectDomains),
190
+ exclude_paths: splitList(args.excludePaths),
191
+ exclude_domains: splitList(args.excludeDomains),
192
+ allow_external: !!args.allowExternal,
193
+ categories: splitList(args.categories),
194
+ timeout: args.timeout ? Number(args.timeout) : undefined,
195
+ include_usage: true,
196
+ });
197
+ const { status, ok, data, latency_ms } = await doFetch('/map', body, ctx);
198
+ if (!ok) throw asError(status, data);
199
+ const urls = (data.results || []).map(it => typeof it === 'string' ? it : it.url).filter(Boolean);
200
+ return wrap('map', data, { base_url: data.base_url || args.url, urls }, latency_ms);
201
+ }
202
+
203
+ async function researchStart(args, ctx) {
204
+ const body = compactObject({
205
+ input: args.input,
206
+ model: args.model || 'auto',
207
+ citation_format: args.citationFormat || 'numbered',
208
+ stream: false,
209
+ output_schema: args.outputSchema,
210
+ });
211
+ const { status, ok, data, latency_ms } = await doFetch('/research', body, ctx, { timeoutMs: 30000 });
212
+ if (!ok) throw asError(status, data);
213
+ return wrap('research-start', data, {
214
+ request_id: `tvly:${data.request_id}`,
215
+ provider_run_id: data.request_id,
216
+ status: data.status || 'pending',
217
+ model: data.model || body.model,
218
+ }, latency_ms);
219
+ }
220
+
221
+ async function researchPoll(args, ctx) {
222
+ const id = args.providerRunId;
223
+ const { status, ok, data, latency_ms } = await doFetch(`/research/${id}`, null, ctx, { method: 'GET', timeoutMs: 15000 });
224
+ if (!ok) throw asError(status, data);
225
+ return wrap('research-poll', data, {
226
+ request_id: `tvly:${id}`,
227
+ provider_run_id: id,
228
+ status: data.status,
229
+ model: data.model,
230
+ content: data.content,
231
+ sources: (data.sources || []).map(s => ({ url: s.url, title: s.title })),
232
+ error: data.error,
233
+ }, latency_ms);
234
+ }
235
+
236
+ async function usage(_args, ctx) {
237
+ const { status, ok, data, latency_ms } = await doFetch('/usage', null, ctx, { method: 'GET', timeoutMs: 15000 });
238
+ if (!ok) throw asError(status, data);
239
+ return wrap('usage', data, data, latency_ms);
240
+ }
241
+
242
+ function asError(status, body) {
243
+ const m = mapError(status, body);
244
+ return Object.assign(new Error(`tavily ${m.kind} (HTTP ${status}): ${m.message}`), m, { body });
245
+ }
@@ -0,0 +1,111 @@
1
+ // Interactive onboarding wizard. Requires a TTY. Non-TTY callers should use
2
+ // `surf-skill keys add` directly.
3
+ //
4
+ // Multi-key: prompts for N keys per provider (Enter to finish that provider).
5
+
6
+ import readline from 'node:readline/promises';
7
+ import { stdin, stdout } from 'node:process';
8
+ import { loadState, saveStateAtomic, KEYS_FILE } from './state.mjs';
9
+
10
+ const BANNER = `
11
+ ┌─ surf-skill setup ──────────────────────────────────────
12
+ │ Configure API keys. You can add multiple keys per provider
13
+ │ (Enter empty to finish a provider; Enter twice in a row to
14
+ │ skip it entirely).
15
+
16
+ │ Tavily: https://app.tavily.com (1,000 free credits/mo)
17
+ │ Parallel: https://platform.parallel.ai
18
+
19
+ │ Keys live in ${KEYS_FILE} (chmod 600).
20
+ └──────────────────────────────────────────────────────────
21
+ `;
22
+
23
+ const CHEAT_SHEET_TPL = (counts) => `
24
+ ✓ Saved ${counts.tav} Tavily key${counts.tav === 1 ? '' : 's'}, ${counts.par} Parallel key${counts.par === 1 ? '' : 's'}.
25
+
26
+ Try one of:
27
+ surf-skill search "your query"
28
+ surf-skill search "q1" "q2" "q3" # batch (N queries)
29
+ surf-skill extract https://example.com
30
+ surf-skill keys list
31
+
32
+ Add another key later with:
33
+ surf-skill keys add --provider <tavily|parallel> <key>
34
+
35
+ 🛠 IMPORTANT — in each project where you'll use surf-skill, run:
36
+ surf-skill project-config
37
+ This raises the per-project bash timeout for the harness in that repo.
38
+
39
+ ⚠ GitHub Copilot CLI users: this step is REQUIRED. Copilot's default bash
40
+ timeout is 30s and surf-skill needs more (most commands run 3–60s).
41
+
42
+ Docs: SKILL.md · Repo: https://github.com/frederico-kluser/surf-skill
43
+ `;
44
+
45
+ async function promptKeys(rl, provider, existing = []) {
46
+ const collected = [];
47
+ let i = 1;
48
+ const seen = new Set(existing);
49
+ while (true) {
50
+ const promptText = i === 1
51
+ ? `${provider} key #${i} (Enter to skip ${provider}): `
52
+ : `${provider} key #${i} (Enter to finish, or paste another): `;
53
+ let ans = '';
54
+ try {
55
+ ans = (await rl.question(promptText)).trim();
56
+ } catch {
57
+ break;
58
+ }
59
+ if (!ans) break;
60
+ if (seen.has(ans)) {
61
+ stdout.write(` (already configured, skipping)\n`);
62
+ continue;
63
+ }
64
+ collected.push(ans);
65
+ seen.add(ans);
66
+ i++;
67
+ }
68
+ return collected;
69
+ }
70
+
71
+ export async function runSetup() {
72
+ if (!stdin.isTTY) {
73
+ const err = new Error(`'setup' requires a TTY. Use:
74
+ surf-skill keys add --provider tavily <key>
75
+ surf-skill keys add --provider parallel <key>`);
76
+ err.code = 'NO_TTY';
77
+ throw err;
78
+ }
79
+
80
+ stdout.write(BANNER);
81
+
82
+ const state = await loadState();
83
+ const rl = readline.createInterface({ input: stdin, output: stdout });
84
+ let newTav = [];
85
+ let newPar = [];
86
+ try {
87
+ newTav = await promptKeys(rl, 'Tavily', state.tavily.keys);
88
+ stdout.write('\n');
89
+ newPar = await promptKeys(rl, 'Parallel', state.parallel.keys);
90
+ } finally {
91
+ rl.close();
92
+ }
93
+
94
+ if (!newTav.length && !newPar.length) {
95
+ stdout.write('\nNo new keys provided. Rerun with: surf-skill setup\n');
96
+ return { addedTavily: 0, addedParallel: 0 };
97
+ }
98
+
99
+ for (const k of newTav) state.tavily.keys.push(k);
100
+ for (const k of newPar) state.parallel.keys.push(k);
101
+ if (state.tavily.keys.length && state.tavily.current >= state.tavily.keys.length) state.tavily.current = 0;
102
+ if (state.parallel.keys.length && state.parallel.current >= state.parallel.keys.length) state.parallel.current = 0;
103
+
104
+ await saveStateAtomic(state);
105
+
106
+ stdout.write(CHEAT_SHEET_TPL({
107
+ tav: state.tavily.keys.length,
108
+ par: state.parallel.keys.length,
109
+ }));
110
+ return { addedTavily: newTav.length, addedParallel: newPar.length };
111
+ }