surf-skill 2.1.1 → 4.0.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 +264 -0
- package/README.md +119 -77
- package/SKILL.md +52 -52
- package/bin/surf-plan-skill.mjs +180 -0
- package/bin/{surf-skill.mjs → surf-search-skill.mjs} +41 -30
- package/bin/surf.mjs +314 -0
- package/logo.png +0 -0
- package/package.json +15 -5
- package/references/parallel-api.md +1 -1
- package/references/plan-workflow.md +137 -0
- package/skills/surf-plan-skill/SKILL.md +260 -0
- package/src/index.mjs +6 -3
- package/src/install/postinstall.mjs +8 -4
- package/src/lib/check-surf-skill.mjs +46 -0
- package/src/lib/dispatch.mjs +4 -4
- package/src/lib/format.mjs +1 -1
- package/src/lib/harness-install.mjs +34 -11
- package/src/lib/keys-cmd.mjs +31 -6
- package/src/lib/project-config.mjs +3 -3
- package/src/lib/setup.mjs +57 -21
- package/src/plan/plan-file.mjs +170 -0
- package/src/plan/plans-dir.mjs +46 -0
- package/src/plan/slug.mjs +55 -0
- package/src/validators/index.mjs +129 -0
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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:
|
|
122
|
-
addedParallel:
|
|
123
|
-
addedBrave:
|
|
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
|
+
}
|