surf-skill 2.1.1 → 4.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.
package/src/lib/setup.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // Interactive onboarding wizard. Requires a TTY. Non-TTY callers should use
2
- // `surf-skill keys add` directly.
2
+ // `surf-search-skill keys add` directly.
3
3
  //
4
4
  // Multi-key: prompts for N keys per provider (Enter to finish that provider).
5
5
  // 3 providers: Tavily, Parallel, Brave.
@@ -7,9 +7,10 @@
7
7
  import readline from 'node:readline/promises';
8
8
  import { stdin, stdout } from 'node:process';
9
9
  import { loadState, saveStateAtomic, KEYS_FILE } from './state.mjs';
10
+ import { validateKey, formatValidation } from '../validators/index.mjs';
10
11
 
11
12
  const BANNER = `
12
- ┌─ surf-skill setup ──────────────────────────────────────
13
+ ┌─ surf-search-skill setup ──────────────────────────────────────
13
14
  │ Configure API keys. You can add multiple keys per provider
14
15
  │ (Enter empty to finish a provider; Enter twice in a row to
15
16
  │ skip it entirely).
@@ -26,21 +27,21 @@ const CHEAT_SHEET_TPL = (counts) => `
26
27
  ✓ 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'}.
27
28
 
28
29
  Try one of:
29
- surf-skill search "your query"
30
- surf-skill search "q1" "q2" "q3" # batch (N queries)
31
- surf-skill search "x" --provider brave --mode fast
32
- surf-skill extract https://example.com
33
- surf-skill keys list
30
+ surf-search-skill search "your query"
31
+ surf-search-skill search "q1" "q2" "q3" # batch (N queries)
32
+ surf-search-skill search "x" --provider brave --mode fast
33
+ surf-search-skill extract https://example.com
34
+ surf-search-skill keys list
34
35
 
35
36
  Add another key later with:
36
- surf-skill keys add --provider <tavily|parallel|brave> <key>
37
+ surf-search-skill keys add --provider <tavily|parallel|brave> <key>
37
38
 
38
- 🛠 IMPORTANT — in each project where you'll use surf-skill, run:
39
- surf-skill project-config
39
+ 🛠 IMPORTANT — in each project where you'll use surf-search-skill, run:
40
+ surf-search-skill project-config
40
41
  This raises the per-project bash timeout for the harness in that repo.
41
42
 
42
43
  ⚠ GitHub Copilot CLI users: this step is REQUIRED. Copilot's default bash
43
- timeout is 30s and surf-skill needs more (most commands run 3–60s).
44
+ timeout is 30s and surf-search-skill needs more (most commands run 3–60s).
44
45
 
45
46
  Docs: SKILL.md · Repo: https://github.com/frederico-kluser/surf-skill
46
47
  `;
@@ -74,9 +75,9 @@ async function promptKeys(rl, provider, existing = []) {
74
75
  export async function runSetup() {
75
76
  if (!stdin.isTTY) {
76
77
  const err = new Error(`'setup' requires a TTY. Use:
77
- surf-skill keys add --provider tavily <key>
78
- surf-skill keys add --provider parallel <key>
79
- surf-skill keys add --provider brave <key>`);
78
+ surf-search-skill keys add --provider tavily <key>
79
+ surf-search-skill keys add --provider parallel <key>
80
+ surf-search-skill keys add --provider brave <key>`);
80
81
  err.code = 'NO_TTY';
81
82
  throw err;
82
83
  }
@@ -99,13 +100,47 @@ export async function runSetup() {
99
100
  }
100
101
 
101
102
  if (!newTav.length && !newPar.length && !newBrv.length) {
102
- stdout.write('\nNo new keys provided. Rerun with: surf-skill setup\n');
103
+ stdout.write('\nNo new keys provided. Rerun with: surf-search-skill setup\n');
103
104
  return { addedTavily: 0, addedParallel: 0, addedBrave: 0 };
104
105
  }
105
106
 
106
- for (const k of newTav) state.tavily.keys.push(k);
107
- for (const k of newPar) state.parallel.keys.push(k);
108
- for (const k of newBrv) state.brave.keys.push(k);
107
+ // Live-validate every freshly collected key before persisting. Invalid
108
+ // keys are dropped from the batch with a clear message. The user
109
+ // doesn't waste hours wondering why fallback isn't kicking in.
110
+ stdout.write('\n— Validating new keys against each provider (1 credit each) —\n');
111
+ const keptTav = [];
112
+ for (const k of newTav) {
113
+ stdout.write(` tavily ${k.slice(0, 5)}…${k.slice(-4)} → `);
114
+ const r = await validateKey('tavily', k);
115
+ stdout.write(formatValidation(r) + '\n');
116
+ if (r.valid) keptTav.push(k);
117
+ }
118
+ const keptPar = [];
119
+ for (const k of newPar) {
120
+ stdout.write(` parallel ${k.slice(0, 5)}…${k.slice(-4)} → `);
121
+ const r = await validateKey('parallel', k);
122
+ stdout.write(formatValidation(r) + '\n');
123
+ if (r.valid) keptPar.push(k);
124
+ }
125
+ const keptBrv = [];
126
+ for (const k of newBrv) {
127
+ stdout.write(` brave ${k.slice(0, 5)}…${k.slice(-4)} → `);
128
+ const r = await validateKey('brave', k);
129
+ stdout.write(formatValidation(r) + '\n');
130
+ if (r.valid) keptBrv.push(k);
131
+ }
132
+ const dropped = (newTav.length - keptTav.length) + (newPar.length - keptPar.length) + (newBrv.length - keptBrv.length);
133
+ if (dropped) {
134
+ stdout.write(`\n⚠ ${dropped} key${dropped === 1 ? '' : 's'} failed validation and were NOT saved.\n`);
135
+ }
136
+ if (!keptTav.length && !keptPar.length && !keptBrv.length) {
137
+ stdout.write('\nNo valid keys to save. Re-run `surf-search-skill setup` with working keys.\n');
138
+ return { addedTavily: 0, addedParallel: 0, addedBrave: 0, dropped };
139
+ }
140
+
141
+ for (const k of keptTav) state.tavily.keys.push(k);
142
+ for (const k of keptPar) state.parallel.keys.push(k);
143
+ for (const k of keptBrv) state.brave.keys.push(k);
109
144
  if (state.tavily.keys.length && state.tavily.current >= state.tavily.keys.length) state.tavily.current = 0;
110
145
  if (state.parallel.keys.length && state.parallel.current >= state.parallel.keys.length) state.parallel.current = 0;
111
146
  if (state.brave.keys.length && state.brave.current >= state.brave.keys.length) state.brave.current = 0;
@@ -118,8 +153,9 @@ export async function runSetup() {
118
153
  brv: state.brave.keys.length,
119
154
  }));
120
155
  return {
121
- addedTavily: newTav.length,
122
- addedParallel: newPar.length,
123
- addedBrave: newBrv.length,
156
+ addedTavily: keptTav.length,
157
+ addedParallel: keptPar.length,
158
+ addedBrave: keptBrv.length,
159
+ dropped,
124
160
  };
125
161
  }
@@ -0,0 +1,170 @@
1
+ // Read, write, and list plan files.
2
+
3
+ import { existsSync, promises as fs } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { resolvePlansDir } from './plans-dir.mjs';
6
+ import { planFilename, slugify } from './slug.mjs';
7
+
8
+ /**
9
+ * Write a plan file. Returns the absolute path written.
10
+ *
11
+ * @param {object} plan
12
+ * @param {string} plan.task - task title (also seeds the slug)
13
+ * @param {string} plan.context - the "why"
14
+ * @param {Array<{name:string, value:string, citation?:number}>} [plan.decisions]
15
+ * @param {Array<string>} [plan.files]
16
+ * @param {Array<string|{title:string, body?:string}>} [plan.steps]
17
+ * @param {Array<string>} [plan.verification]
18
+ * @param {Array<{title:string, url:string}>} [plan.references]
19
+ * @param {object} [opts]
20
+ * @param {string} [opts.dir] - override resolvePlansDir()
21
+ * @param {Date} [opts.now=new Date()]
22
+ * @returns {Promise<string>} absolute path of the written file
23
+ */
24
+ export async function writePlan(plan, opts = {}) {
25
+ if (!plan || typeof plan !== 'object') throw new Error('writePlan: plan object required');
26
+ if (!plan.task || typeof plan.task !== 'string') throw new Error('writePlan: plan.task required');
27
+
28
+ const dir = opts.dir || await resolvePlansDir();
29
+ await fs.mkdir(dir, { recursive: true });
30
+
31
+ const baseName = planFilename(plan.task, opts.now);
32
+ // Collision avoidance: -2, -3, etc.
33
+ let filePath = path.join(dir, baseName);
34
+ let n = 2;
35
+ while (existsSync(filePath)) {
36
+ const noExt = baseName.replace(/\.md$/, '');
37
+ filePath = path.join(dir, `${noExt}-${n}.md`);
38
+ n++;
39
+ }
40
+
41
+ const md = renderPlanMarkdown(plan);
42
+ await fs.writeFile(filePath, md, 'utf8');
43
+ return filePath;
44
+ }
45
+
46
+ function renderPlanMarkdown(plan) {
47
+ const out = [];
48
+ out.push(`# Plan: ${plan.task.trim()}\n`);
49
+
50
+ out.push('## Context\n');
51
+ out.push(`${(plan.context || '_TBD — describe why this is being done and what success looks like._').trim()}\n`);
52
+
53
+ if (plan.decisions && plan.decisions.length) {
54
+ out.push('\n## Decisions\n');
55
+ for (const d of plan.decisions) {
56
+ const citation = d.citation ? `[^${d.citation}]` : '';
57
+ out.push(`- **${d.name}**: ${d.value}${citation ? ` ${citation}` : ''}`);
58
+ }
59
+ out.push('');
60
+ }
61
+
62
+ if (plan.files && plan.files.length) {
63
+ out.push('\n## Files to modify\n');
64
+ for (const f of plan.files) out.push(`- \`${f}\``);
65
+ out.push('');
66
+ }
67
+
68
+ if (plan.steps && plan.steps.length) {
69
+ out.push('\n## Implementation steps\n');
70
+ plan.steps.forEach((s, i) => {
71
+ if (typeof s === 'string') {
72
+ out.push(`${i + 1}. ${s}`);
73
+ } else {
74
+ const title = s.title || `Step ${i + 1}`;
75
+ const body = s.body ? ` — ${s.body}` : '';
76
+ out.push(`${i + 1}. **${title}**${body}`);
77
+ }
78
+ });
79
+ out.push('');
80
+ }
81
+
82
+ if (plan.verification && plan.verification.length) {
83
+ out.push('\n## Verification\n');
84
+ for (const v of plan.verification) out.push(`- ${v}`);
85
+ out.push('');
86
+ }
87
+
88
+ if (plan.references && plan.references.length) {
89
+ out.push('\n## References\n');
90
+ plan.references.forEach((r, i) => {
91
+ const n = i + 1;
92
+ out.push(`[^${n}]: [${r.title}](${r.url})`);
93
+ });
94
+ out.push('');
95
+ }
96
+
97
+ return out.join('\n');
98
+ }
99
+
100
+ /**
101
+ * Read a plan file by absolute path or by slug substring (resolves against
102
+ * the active plans dir).
103
+ *
104
+ * @param {string} pathOrSlug
105
+ * @returns {Promise<{path: string, content: string}>}
106
+ */
107
+ export async function readPlan(pathOrSlug) {
108
+ let p = pathOrSlug;
109
+ if (!existsSync(p)) {
110
+ const dir = await resolvePlansDir({ ensure: false });
111
+ const matches = (await fs.readdir(dir))
112
+ .filter(f => f.endsWith('.md') && f.includes(pathOrSlug))
113
+ .sort()
114
+ .reverse();
115
+ if (!matches.length) {
116
+ throw new Error(`No plan file found matching '${pathOrSlug}' in ${dir}`);
117
+ }
118
+ p = path.join(dir, matches[0]);
119
+ }
120
+ const content = await fs.readFile(p, 'utf8');
121
+ return { path: p, content };
122
+ }
123
+
124
+ /**
125
+ * List plan files in the active plans dir (newest first).
126
+ *
127
+ * @param {object} [opts]
128
+ * @param {string} [opts.dir] - override resolvePlansDir()
129
+ * @returns {Promise<Array<{path:string, name:string, mtime:Date, size:number, title:string}>>}
130
+ */
131
+ export async function listPlans(opts = {}) {
132
+ const dir = opts.dir || await resolvePlansDir({ ensure: false });
133
+ if (!existsSync(dir)) return [];
134
+ const files = (await fs.readdir(dir)).filter(f => f.endsWith('.md'));
135
+ const out = [];
136
+ for (const name of files) {
137
+ const p = path.join(dir, name);
138
+ const st = await fs.stat(p);
139
+ // Title: first line starting with `# Plan: ` (or just the first line).
140
+ let title = name.replace(/\.md$/, '');
141
+ try {
142
+ const head = (await fs.readFile(p, 'utf8')).split('\n', 5).join('\n');
143
+ const m = head.match(/^#\s*Plan:\s*(.+)$/m);
144
+ if (m) title = m[1].trim();
145
+ } catch {}
146
+ out.push({ path: p, name, mtime: st.mtime, size: st.size, title });
147
+ }
148
+ out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
149
+ return out;
150
+ }
151
+
152
+ /**
153
+ * Create a stub plan file (mostly empty, with placeholders). Used by
154
+ * `surf-plan-skill new "<task>"` so the user — or the agent — can fill it in.
155
+ *
156
+ * @param {string} task
157
+ * @param {object} [opts]
158
+ * @returns {Promise<string>} absolute path written
159
+ */
160
+ export async function newPlanStub(task, opts = {}) {
161
+ return writePlan({
162
+ task,
163
+ context: '_TBD — Phase 1 (project discovery) + Phase 2 (web research) go here._',
164
+ decisions: [],
165
+ files: [],
166
+ steps: [],
167
+ verification: [],
168
+ references: [],
169
+ }, opts);
170
+ }
@@ -0,0 +1,46 @@
1
+ // Resolve where plan files should be written.
2
+ //
3
+ // Order:
4
+ // 1. $SURF_PLAN_DIR env var (explicit override — always wins)
5
+ // 2. ./plans/ if it exists in process.cwd()
6
+ // 3. ./.surf-plans/ if it exists in process.cwd()
7
+ // 4. ~/.claude/plans/ (default; creates the dir if missing)
8
+
9
+ import { existsSync, promises as fs } from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ const HOME = os.homedir();
14
+ const HOME_PLANS = path.join(HOME, '.claude', 'plans');
15
+
16
+ export const DEFAULT_HOME_PLANS = HOME_PLANS;
17
+
18
+ /**
19
+ * @param {object} [opts]
20
+ * @param {string} [opts.cwd=process.cwd()]
21
+ * @param {boolean} [opts.ensure=true] - create the directory if it doesn't exist
22
+ * @returns {Promise<string>}
23
+ */
24
+ export async function resolvePlansDir(opts = {}) {
25
+ const cwd = opts.cwd || process.cwd();
26
+ const ensure = opts.ensure !== false;
27
+
28
+ // 1. Explicit override.
29
+ if (process.env.SURF_PLAN_DIR) {
30
+ const p = path.resolve(process.env.SURF_PLAN_DIR);
31
+ if (ensure) await fs.mkdir(p, { recursive: true });
32
+ return p;
33
+ }
34
+
35
+ // 2. Project-level ./plans/
36
+ const projectPlans = path.join(cwd, 'plans');
37
+ if (existsSync(projectPlans)) return projectPlans;
38
+
39
+ // 3. Hidden ./.surf-plans/
40
+ const hidden = path.join(cwd, '.surf-plans');
41
+ if (existsSync(hidden)) return hidden;
42
+
43
+ // 4. Default ~/.claude/plans/
44
+ if (ensure) await fs.mkdir(HOME_PLANS, { recursive: true });
45
+ return HOME_PLANS;
46
+ }
@@ -0,0 +1,55 @@
1
+ // kebab-case slug from a free-form task title.
2
+
3
+ const STOP_WORDS = new Set([
4
+ 'a', 'an', 'the', 'and', 'or', 'but', 'for', 'with', 'of', 'in', 'on',
5
+ 'at', 'to', 'from', 'by', 'as', 'is', 'are', 'be', 'do', 'does',
6
+ ]);
7
+
8
+ /**
9
+ * Build a deterministic kebab-case slug from a task title.
10
+ * - lowercases
11
+ * - strips diacritics (ã → a)
12
+ * - removes punctuation
13
+ * - drops short stop words
14
+ * - joins with `-`
15
+ * - max 50 chars (truncates on word boundary when possible)
16
+ *
17
+ * @param {string} title
18
+ * @returns {string}
19
+ */
20
+ export function slugify(title) {
21
+ if (!title || typeof title !== 'string') return 'untitled';
22
+ // Strip diacritics: NFD + remove combining marks.
23
+ const ascii = title.normalize('NFD').replace(/[̀-ͯ]/g, '');
24
+ const tokens = ascii
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9\s-]/g, ' ')
27
+ .split(/\s+/)
28
+ .filter(t => t && !STOP_WORDS.has(t));
29
+ if (!tokens.length) return 'untitled';
30
+ let slug = tokens.join('-').replace(/-+/g, '-').replace(/^-|-$/g, '');
31
+ if (slug.length > 50) {
32
+ // Truncate on a hyphen if possible.
33
+ slug = slug.slice(0, 50);
34
+ const lastHyphen = slug.lastIndexOf('-');
35
+ if (lastHyphen > 20) slug = slug.slice(0, lastHyphen);
36
+ }
37
+ return slug || 'untitled';
38
+ }
39
+
40
+ /**
41
+ * Build a filename: `<slug>-<YYYYMMDD-HHMM>.md`.
42
+ *
43
+ * @param {string} title
44
+ * @param {Date} [now=new Date()]
45
+ * @returns {string}
46
+ */
47
+ export function planFilename(title, now = new Date()) {
48
+ const slug = slugify(title);
49
+ const y = now.getUTCFullYear();
50
+ const mo = String(now.getUTCMonth() + 1).padStart(2, '0');
51
+ const d = String(now.getUTCDate()).padStart(2, '0');
52
+ const h = String(now.getUTCHours()).padStart(2, '0');
53
+ const mi = String(now.getUTCMinutes()).padStart(2, '0');
54
+ return `${slug}-${y}${mo}${d}-${h}${mi}.md`;
55
+ }
@@ -0,0 +1,129 @@
1
+ // Per-provider key validators.
2
+ //
3
+ // Each validator runs a real 1-credit search call against the provider's
4
+ // API using the existing adapter. If the call returns 200, the key is
5
+ // valid; auth/billing errors mark it invalid; other errors are surfaced
6
+ // with their kind so the caller can decide whether to save anyway.
7
+ //
8
+ // Cost per validation:
9
+ // - Tavily: 1 credit (~$0.001)
10
+ // - Parallel: ~1 credit (lite tier)
11
+ // - Brave: ~$0.003 (metered)
12
+ //
13
+ // This is a one-time cost per added key. Acceptable trade-off for
14
+ // "saved a working key" vs "saved a dead key and discovered hours later".
15
+
16
+ import { tavilyProvider } from '../lib/providers/tavily.mjs';
17
+ import { parallelProvider } from '../lib/providers/parallel.mjs';
18
+ import { braveProvider } from '../lib/providers/brave.mjs';
19
+
20
+ const ADAPTERS = {
21
+ tavily: tavilyProvider,
22
+ parallel: parallelProvider,
23
+ brave: braveProvider,
24
+ };
25
+
26
+ const VERSION = '3.0.1';
27
+ const VALIDATION_QUERY = 'surf-skill key validation ping';
28
+ const TIMEOUT_MS = 20_000;
29
+
30
+ /**
31
+ * Validate a single API key by making a live search call.
32
+ *
33
+ * @param {string} provider - 'tavily' | 'parallel' | 'brave'
34
+ * @param {string} key
35
+ * @returns {Promise<{
36
+ * valid: boolean,
37
+ * provider: string,
38
+ * latency_ms?: number,
39
+ * credits?: number,
40
+ * kind?: string,
41
+ * statusCode?: number,
42
+ * error?: string,
43
+ * }>}
44
+ */
45
+ export async function validateKey(provider, key) {
46
+ const adapter = ADAPTERS[provider];
47
+ if (!adapter) {
48
+ return {
49
+ valid: false,
50
+ provider,
51
+ kind: 'unknown_provider',
52
+ error: `unknown provider: ${provider}. Use: tavily | parallel | brave`,
53
+ };
54
+ }
55
+ if (!key || typeof key !== 'string' || key.length < 8) {
56
+ return {
57
+ valid: false,
58
+ provider,
59
+ kind: 'malformed',
60
+ error: 'key is empty or too short',
61
+ };
62
+ }
63
+
64
+ const ctx = { key, timeout: TIMEOUT_MS, version: VERSION };
65
+ const t0 = Date.now();
66
+ try {
67
+ const result = await adapter.search(
68
+ { query: VALIDATION_QUERY, max: 1, mode: 'fast' },
69
+ ctx,
70
+ );
71
+ return {
72
+ valid: true,
73
+ provider,
74
+ latency_ms: Date.now() - t0,
75
+ credits: (result && result.usage && result.usage.credits) || 1,
76
+ };
77
+ } catch (e) {
78
+ return {
79
+ valid: false,
80
+ provider,
81
+ latency_ms: Date.now() - t0,
82
+ kind: e.kind || 'network',
83
+ statusCode: e.statusCode,
84
+ error: e.message || String(e),
85
+ };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Validate multiple keys, optionally in parallel.
91
+ *
92
+ * @param {Array<{provider: string, key: string}>} items
93
+ * @param {object} [opts]
94
+ * @param {boolean} [opts.parallel=false] - run all validations in parallel
95
+ * @returns {Promise<Array>}
96
+ */
97
+ export async function validateAll(items, opts = {}) {
98
+ if (opts.parallel) {
99
+ return Promise.all(items.map(it => validateKey(it.provider, it.key)));
100
+ }
101
+ const out = [];
102
+ for (const it of items) out.push(await validateKey(it.provider, it.key));
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * Human-readable summary of a validation result.
108
+ *
109
+ * @param {object} r - result from validateKey
110
+ * @returns {string}
111
+ */
112
+ export function formatValidation(r) {
113
+ if (r.valid) {
114
+ return `✓ valid (${r.provider}, HTTP 200, ${r.latency_ms}ms, ${r.credits} credit${r.credits === 1 ? '' : 's'})`;
115
+ }
116
+ const kindMap = {
117
+ auth: 'invalid key (401/403/422)',
118
+ rate_limit_429: 'rate limit hit — key likely valid but throttled, try again',
119
+ server_5xx: "provider's server is down — try again later",
120
+ network: 'network error reaching provider',
121
+ malformed: 'key format is invalid',
122
+ unknown_provider:'unknown provider',
123
+ not_supported: 'provider does not support this validation method',
124
+ };
125
+ const reason = kindMap[r.kind] || r.kind || 'unknown error';
126
+ const status = r.statusCode ? ` HTTP ${r.statusCode}` : '';
127
+ const msg = r.error ? ` — ${r.error}` : '';
128
+ return `✗ ${reason}${status}${msg}`;
129
+ }