surf-skill 2.1.0 → 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/bin/surf.mjs ADDED
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ // `surf` — bundle wrapper for surf-search-skill + surf-plan-skill.
3
+ //
4
+ // Running `surf` with no args launches an interactive setup that:
5
+ // 1. Verifies both skills are installed (symlinks present)
6
+ // 2. Lists currently-configured keys per provider
7
+ // 3. Offers an interactive menu: add / list / remove / doctor / quit
8
+ // 4. EVERY key added is validated LIVE against the provider's API
9
+ // before being saved (1-credit cost, ~1-3s per validation)
10
+ //
11
+ // This is the friendliest entry point. `surf-search-skill` and `surf-plan-skill`
12
+ // remain available for power users and scripts.
13
+
14
+ import readline from 'node:readline/promises';
15
+ import { stdin, stdout, stderr } from 'node:process';
16
+ import { existsSync, promises as fs } from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ import { loadState, saveStateAtomic, KEYS_FILE, PROVIDERS } from '../src/lib/state.mjs';
21
+ import { validateKey, formatValidation } from '../src/validators/index.mjs';
22
+ import { HARNESS_DIRS } from '../src/lib/harness-install.mjs';
23
+
24
+ const VERSION = '4.0.0';
25
+
26
+ const HELP = `surf — multi-skill setup & validation
27
+
28
+ Bundles surf-search-skill (multi-provider web search) and surf-plan-skill
29
+ (research-driven execution planning) into one command.
30
+
31
+ Commands:
32
+ (no args) Interactive setup wizard (add keys with live validation)
33
+ add Add a key (you'll be asked for provider + key)
34
+ list List configured keys (masked) + last-known state
35
+ validate [provider] Re-validate all keys (or just one provider's)
36
+ remove <provider> <i> Remove key #i from provider
37
+ doctor Diagnostics: skills installed? keys valid? harness symlinks?
38
+ --help, -h Show this help
39
+ --version, -v Show version
40
+
41
+ Power-user CLIs (also installed):
42
+ surf-search-skill ... The search engine (search/extract/crawl/map/research)
43
+ surf-plan-skill ... The planning skill (list/show/new/doctor)
44
+
45
+ Keys live in: ${KEYS_FILE} (chmod 600)
46
+ Plans live in: ~/.claude/plans/<slug>-<timestamp>.md (or ./plans/)
47
+ SKILL.md (search): ~/.agents/skills/surf-search-skill/SKILL.md
48
+ SKILL.md (planning): ~/.agents/skills/surf-plan-skill/SKILL.md
49
+ `;
50
+
51
+ function out(s = '') {
52
+ stdout.write(s + (s.endsWith('\n') ? '' : '\n'));
53
+ }
54
+ function err(s) {
55
+ stderr.write(s + (s.endsWith('\n') ? '' : '\n'));
56
+ }
57
+ function mask(key) {
58
+ if (!key || key.length < 8) return key;
59
+ return key.slice(0, 5) + '…' + key.slice(-4);
60
+ }
61
+ function fmtBytes(n) {
62
+ if (n < 1024) return `${n}B`;
63
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
64
+ return `${(n / 1024 / 1024).toFixed(1)}MB`;
65
+ }
66
+
67
+ async function detectSkills() {
68
+ const home = os.homedir();
69
+ const skillsToCheck = ['surf-search-skill', 'surf-plan-skill'];
70
+ const found = {};
71
+ for (const skill of skillsToCheck) {
72
+ found[skill] = { dirs: [] };
73
+ for (const dir of HARNESS_DIRS) {
74
+ const link = path.join(dir, skill);
75
+ if (existsSync(link)) {
76
+ try {
77
+ const stat = await fs.lstat(link);
78
+ found[skill].dirs.push({
79
+ path: link,
80
+ isSymlink: stat.isSymbolicLink(),
81
+ isDir: stat.isDirectory(),
82
+ });
83
+ } catch {}
84
+ }
85
+ }
86
+ }
87
+ return found;
88
+ }
89
+
90
+ async function cmdList() {
91
+ const state = await loadState();
92
+ out(`**Keys** (config: \`${KEYS_FILE}\`, chmod 600)`);
93
+ out(`last_ok_provider: \`${state.last_ok_provider || 'none yet'}\``);
94
+ out('');
95
+ for (const p of PROVIDERS) {
96
+ const ps = state[p];
97
+ out(`## ${p} (${ps.keys.length} key${ps.keys.length === 1 ? '' : 's'})`);
98
+ if (!ps.keys.length) {
99
+ out(` _no keys — add with \`surf add\`_`);
100
+ continue;
101
+ }
102
+ for (let i = 0; i < ps.keys.length; i++) {
103
+ const isCur = i === ps.current ? ' (current)' : '';
104
+ const burned = ps.burned.find(b => b.index === i);
105
+ const burn = burned ? ` BURNED:${burned.reason} at ${burned.at.slice(0, 16)}` : '';
106
+ out(` [${i}] ${mask(ps.keys[i])}${isCur}${burn}`);
107
+ }
108
+ }
109
+ }
110
+
111
+ async function cmdValidate(providerFilter) {
112
+ const state = await loadState();
113
+ let any = false;
114
+ for (const p of PROVIDERS) {
115
+ if (providerFilter && p !== providerFilter) continue;
116
+ const ps = state[p];
117
+ if (!ps.keys.length) continue;
118
+ any = true;
119
+ out(`\n## ${p}`);
120
+ for (let i = 0; i < ps.keys.length; i++) {
121
+ stdout.write(` [${i}] ${mask(ps.keys[i])} → `);
122
+ const r = await validateKey(p, ps.keys[i]);
123
+ out(formatValidation(r));
124
+ }
125
+ }
126
+ if (!any) out(providerFilter ? `No keys for ${providerFilter}.` : 'No keys configured. Add one with `surf add`.');
127
+ }
128
+
129
+ async function cmdRemove(args) {
130
+ const [provider, indexStr] = args;
131
+ if (!provider || indexStr == null) {
132
+ err('Usage: surf remove <provider> <index>');
133
+ process.exit(1);
134
+ }
135
+ if (!PROVIDERS.includes(provider)) {
136
+ err(`Unknown provider: ${provider}. Use: ${PROVIDERS.join('|')}`);
137
+ process.exit(1);
138
+ }
139
+ const idx = Number(indexStr);
140
+ const state = await loadState();
141
+ const ps = state[provider];
142
+ if (!Number.isInteger(idx) || idx < 0 || idx >= ps.keys.length) {
143
+ err(`Invalid index ${indexStr}; ${provider} has ${ps.keys.length} key${ps.keys.length === 1 ? '' : 's'} (0-${ps.keys.length - 1}).`);
144
+ process.exit(1);
145
+ }
146
+ const removed = ps.keys.splice(idx, 1)[0];
147
+ ps.burned = ps.burned.filter(b => b.index !== idx).map(b => ({ ...b, index: b.index > idx ? b.index - 1 : b.index }));
148
+ if (ps.current >= ps.keys.length) ps.current = 0;
149
+ await saveStateAtomic(state);
150
+ out(`✓ removed ${provider} key #${idx} (${mask(removed)})`);
151
+ }
152
+
153
+ async function cmdAdd(rl) {
154
+ rl = rl || readline.createInterface({ input: stdin, output: stdout });
155
+ let closeRl = !arguments.length;
156
+ try {
157
+ out('');
158
+ let provider = '';
159
+ while (!PROVIDERS.includes(provider)) {
160
+ provider = (await rl.question(`Provider [${PROVIDERS.join('/')}]: `)).trim().toLowerCase();
161
+ if (!PROVIDERS.includes(provider)) out(` (unknown: ${provider}. Try: ${PROVIDERS.join(', ')})`);
162
+ }
163
+ const key = (await rl.question(`${provider} key: `)).trim();
164
+ if (!key) { out('(empty — cancelled)'); return; }
165
+
166
+ const state = await loadState();
167
+ const ps = state[provider];
168
+ if (ps.keys.includes(key)) {
169
+ out(` ℹ already configured at index ${ps.keys.indexOf(key)} — skipping`);
170
+ return;
171
+ }
172
+
173
+ out(` validating against ${provider} API (1 credit, ~2s)…`);
174
+ const r = await validateKey(provider, key);
175
+ out(` ${formatValidation(r)}`);
176
+ if (!r.valid) {
177
+ out(' → key NOT saved. Try `surf add` again with a different key.');
178
+ process.exitCode = 1;
179
+ return;
180
+ }
181
+
182
+ ps.keys.push(key);
183
+ if (ps.keys.length === 1) ps.current = 0;
184
+ await saveStateAtomic(state);
185
+ out(`✓ saved as ${provider} key #${ps.keys.length - 1}. Total ${provider}: ${ps.keys.length}.`);
186
+ } finally {
187
+ if (closeRl) rl.close();
188
+ }
189
+ }
190
+
191
+ async function cmdDoctor() {
192
+ out('## Skills');
193
+ const found = await detectSkills();
194
+ for (const [skill, info] of Object.entries(found)) {
195
+ if (!info.dirs.length) {
196
+ out(` ✗ ${skill}: NOT found in any harness skill dir`);
197
+ out(` → reinstall: npm i -g surf-skill@latest`);
198
+ process.exitCode = 1;
199
+ } else {
200
+ const sample = info.dirs[0];
201
+ out(` ✓ ${skill}: ${info.dirs.length} harness${info.dirs.length === 1 ? '' : 'es'}`);
202
+ for (const d of info.dirs) out(` ${d.path}${d.isSymlink ? ' (symlink)' : ''}`);
203
+ }
204
+ }
205
+
206
+ out('\n## Keys');
207
+ const state = await loadState();
208
+ const totals = PROVIDERS.map(p => ({ p, n: state[p].keys.length, burned: state[p].burned.length }));
209
+ for (const t of totals) {
210
+ const status = t.n === 0 ? '⚠ no keys' : t.burned ? `${t.n} key(s), ${t.burned} burned` : `${t.n} key(s) ✓`;
211
+ out(` ${t.p.padEnd(10)} ${status}`);
212
+ }
213
+ if (totals.every(t => t.n === 0)) {
214
+ out('\n → Run `surf` to add your first key.');
215
+ process.exitCode = 2;
216
+ }
217
+
218
+ out('\n## Plans');
219
+ const plansDir = path.join(os.homedir(), '.claude', 'plans');
220
+ if (existsSync(plansDir)) {
221
+ const files = (await fs.readdir(plansDir)).filter(f => f.endsWith('.md'));
222
+ out(` ${files.length} plan file${files.length === 1 ? '' : 's'} in ${plansDir}`);
223
+ } else {
224
+ out(` ${plansDir} not created yet`);
225
+ }
226
+ }
227
+
228
+ async function interactiveMenu() {
229
+ out('');
230
+ out('┌─ surf — multi-skill setup & validation ─────────────────');
231
+ out(`│ Skills detected:`);
232
+ const found = await detectSkills();
233
+ for (const [skill, info] of Object.entries(found)) {
234
+ const status = info.dirs.length ? `✓ ${info.dirs.length} harness${info.dirs.length === 1 ? '' : 'es'}` : '✗ NOT INSTALLED';
235
+ out(`│ ${skill.padEnd(20)} ${status}`);
236
+ }
237
+
238
+ const state = await loadState();
239
+ const counts = PROVIDERS.map(p => `${p} ${state[p].keys.length}`).join(', ');
240
+ out(`│ Keys: ${counts}`);
241
+ out(`│ Config: ${KEYS_FILE}`);
242
+ out('└──────────────────────────────────────────────────────────');
243
+ out('');
244
+
245
+ const rl = readline.createInterface({ input: stdin, output: stdout });
246
+ try {
247
+ while (true) {
248
+ out('What do you want to do?');
249
+ out(' [1] Add a key (with live validation)');
250
+ out(' [2] List + revalidate all keys');
251
+ out(' [3] Remove a key');
252
+ out(' [4] Diagnostics (skills + symlinks + dirs)');
253
+ out(' [q] Quit');
254
+ const choice = (await rl.question('> ')).trim().toLowerCase();
255
+ out('');
256
+ if (choice === '1' || choice === 'add') {
257
+ await cmdAdd(rl);
258
+ } else if (choice === '2' || choice === 'list') {
259
+ await cmdValidate();
260
+ } else if (choice === '3' || choice === 'remove') {
261
+ const provider = (await rl.question(`Provider [${PROVIDERS.join('/')}]: `)).trim();
262
+ const idx = (await rl.question('Index: ')).trim();
263
+ await cmdRemove([provider, idx]).catch(e => err(`✗ ${e.message}`));
264
+ } else if (choice === '4' || choice === 'doctor') {
265
+ await cmdDoctor();
266
+ } else if (choice === 'q' || choice === 'quit' || choice === 'exit' || !choice) {
267
+ out('bye 🌊');
268
+ return;
269
+ } else {
270
+ out(`(unknown choice: ${choice})`);
271
+ }
272
+ out('');
273
+ }
274
+ } finally {
275
+ rl.close();
276
+ }
277
+ }
278
+
279
+ const [, , cmd, ...rest] = process.argv;
280
+
281
+ try {
282
+ if (!cmd) {
283
+ if (!stdin.isTTY) {
284
+ err(`surf requires a TTY for interactive setup. Use a subcommand:
285
+ surf add | list | validate | remove <provider> <i> | doctor`);
286
+ process.exit(1);
287
+ }
288
+ await interactiveMenu();
289
+ } else if (cmd === '--help' || cmd === '-h') {
290
+ out(HELP);
291
+ } else if (cmd === '--version' || cmd === '-v') {
292
+ out(VERSION);
293
+ } else if (cmd === 'add') {
294
+ if (!stdin.isTTY) {
295
+ err('`surf add` is interactive and requires a TTY. Use `surf-search-skill keys add --provider X <key>` for scripts.');
296
+ process.exit(1);
297
+ }
298
+ await cmdAdd();
299
+ } else if (cmd === 'list') {
300
+ await cmdList();
301
+ } else if (cmd === 'validate') {
302
+ await cmdValidate(rest[0]);
303
+ } else if (cmd === 'remove') {
304
+ await cmdRemove(rest);
305
+ } else if (cmd === 'doctor') {
306
+ await cmdDoctor();
307
+ } else {
308
+ err(`Unknown command: ${cmd}. Try 'surf --help'.`);
309
+ process.exit(1);
310
+ }
311
+ } catch (e) {
312
+ err(`❌ Error: ${e.message || String(e)}`);
313
+ process.exit(1);
314
+ }
package/logo.png ADDED
Binary file
package/package.json CHANGED
@@ -1,29 +1,35 @@
1
1
  {
2
2
  "name": "surf-skill",
3
- "version": "2.1.0",
4
- "description": "Multi-provider web skill (Tavily + Parallel AI + Brave Search) for AI coding agents CLI + Node library + Anthropic Agent Skill. Auto-fallback, multi-key rotation, --mode tiers, per-project timeout config.",
3
+ "version": "4.0.0",
4
+ "description": "Multi-skill bundle for AI coding agents: surf-search-skill (multi-provider web search via Tavily + Parallel + Brave) + surf-plan-skill (research-driven execution planning). Includes `surf` interactive setup with live key validation, automatic provider fallback, multi-key rotation, --mode tiers, per-project timeout config, and a Node library.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
7
7
  "exports": {
8
8
  ".": "./src/index.mjs",
9
+ "./plan": "./src/plan/plan-file.mjs",
10
+ "./validators": "./src/validators/index.mjs",
9
11
  "./package.json": "./package.json"
10
12
  },
11
13
  "bin": {
12
- "surf-skill": "./bin/surf-skill.mjs"
14
+ "surf": "./bin/surf.mjs",
15
+ "surf-skill": "./bin/surf-skill.mjs",
16
+ "surf-plan-skill": "./bin/surf-plan-skill.mjs"
13
17
  },
14
18
  "files": [
15
19
  "bin/",
16
20
  "src/",
21
+ "skills/",
17
22
  "references/",
18
23
  "SKILL.md",
19
24
  "README.md",
20
25
  "CHANGELOG.md",
21
- "LICENSE"
26
+ "LICENSE",
27
+ "logo.png"
22
28
  ],
23
29
  "scripts": {
24
30
  "postinstall": "node ./src/install/postinstall.mjs || true",
25
31
  "preuninstall": "node ./src/install/preuninstall.mjs || true",
26
- "test:syntax": "node --check bin/surf-skill.mjs && for f in src/index.mjs src/env.mjs src/install/*.mjs src/lib/*.mjs src/lib/providers/*.mjs src/lib/api/*.mjs; do node --check \"$f\" || exit 1; done && echo syntax-ok"
32
+ "test:syntax": "node --check bin/surf.mjs && node --check bin/surf-search-skill.mjs && node --check bin/surf-plan-skill.mjs && for f in src/index.mjs src/env.mjs src/install/*.mjs src/lib/*.mjs src/lib/providers/*.mjs src/lib/api/*.mjs src/plan/*.mjs src/validators/*.mjs; do node --check \"$f\" || exit 1; done && echo syntax-ok"
27
33
  },
28
34
  "engines": {
29
35
  "node": ">=18"
@@ -35,6 +41,10 @@
35
41
  "brave-search",
36
42
  "web-search",
37
43
  "connector",
44
+ "planning",
45
+ "spec-driven",
46
+ "research-driven",
47
+ "execution-plan",
38
48
  "claude-code",
39
49
  "copilot-cli",
40
50
  "pi-coding-agent",
@@ -102,7 +102,7 @@ Returns **HTTP 202** with `{ run_id, status: "queued" \| "running", is_active, p
102
102
  }
103
103
  ```
104
104
 
105
- Processor mapping used by `surf-skill research`:
105
+ Processor mapping used by `surf-search-skill research`:
106
106
 
107
107
  | `--model` | Parallel processor |
108
108
  |---|---|
@@ -0,0 +1,137 @@
1
+ # surf-plan-skill workflow — deeper docs
2
+
3
+ This file expands on the 6-phase workflow defined in `SKILL.md`. It's
4
+ for humans reviewing the methodology, not for the agent (which only
5
+ reads SKILL.md).
6
+
7
+ ## The 6 phases
8
+
9
+ ### Phase 0 — preflight
10
+
11
+ Why: if `surf-search-skill` is missing, web research is impossible, and the
12
+ plan would just be the agent's training-time hallucinations dressed up
13
+ in markdown. Halt is the right move.
14
+
15
+ How: `surf-search-skill --version` exits 0 → continue. Exits non-zero or
16
+ command not found → halt with install instructions.
17
+
18
+ ### Phase 1 — project discovery
19
+
20
+ Why: plans that skip this duplicate existing code. ~5 minutes of
21
+ reading saves hours of integration pain.
22
+
23
+ How:
24
+ 1. `CLAUDE.md`, `AGENTS.md`, `README.md` — house style + constraints.
25
+ 2. Package manifest — language, framework, deps.
26
+ 3. Glob the source tree (2 levels deep).
27
+ 4. Identify 2–3 existing patterns / utilities the new feature should
28
+ reuse.
29
+ 5. Note relevant configs (eslint, tsconfig, docker, ci).
30
+
31
+ Output: agent's mental model of "what already exists" + file paths.
32
+
33
+ ### Phase 2 — baseline web research
34
+
35
+ Why: ground the plan in 2026 best practices, not 2024 training data.
36
+
37
+ How: ONE batched `surf-search-skill search` with 3 queries. Distill:
38
+ - 3 dominant approaches
39
+ - 2–3 common mistakes
40
+ - 1–2 security/performance gotchas
41
+
42
+ Cost: ~6 credits (Tavily) + ~10 s. Acceptable for any non-trivial plan.
43
+
44
+ ### Phase 3 — open the conversation
45
+
46
+ Why: the user needs to see you've done your homework before they
47
+ answer questions.
48
+
49
+ How: ≤8 lines. What you read + what the web says + how many questions
50
+ you have.
51
+
52
+ ### Phase 4 — clarifying questions
53
+
54
+ Why: even after research, certain decisions require the user's
55
+ preference (e.g., "Redis or Postgres for the queue?", "do we need
56
+ multi-tenancy from day one?").
57
+
58
+ How:
59
+ - For each question: targeted `surf-search-skill search --max 2` first.
60
+ - AskUserQuestion with options informed by the search.
61
+ - Max 5 total.
62
+
63
+ Anti-pattern: asking the user to choose between options that the agent
64
+ just made up. Search first; pick options from real approaches.
65
+
66
+ ### Phase 5 — synthesis research
67
+
68
+ Why: verify the user's choices against the very-latest state of the
69
+ art. Catches "you chose X but X v2 dropped support for Y last month".
70
+
71
+ How: ONE batched search with the user's chosen approach. ~6 credits.
72
+ If contradictions appear, flag them BEFORE writing the plan.
73
+
74
+ ### Phase 6 — write the plan
75
+
76
+ Why: a plan file is a contract. It's reviewable, executable, and
77
+ auditable. Chat history is none of those.
78
+
79
+ How: Markdown with the structure from SKILL.md. Required sections:
80
+ Context, Decisions, Files, Steps, Verification, References.
81
+
82
+ Citations use Markdown footnote syntax `[^N]: [Title](URL)` — renders
83
+ in GitHub, GitLab, Bitbucket, Cursor, Plannotator, and most other
84
+ viewers.
85
+
86
+ ## Cost discipline
87
+
88
+ A typical plan uses:
89
+ - 1 batch (Phase 2): 3 queries, ~6 credits, ~10 s
90
+ - 3–5 targeted (Phase 4): 1 query each, ~5 credits, ~3 s each
91
+ - 1 batch (Phase 5): 2–3 queries, ~5 credits, ~8 s
92
+
93
+ Total: ~15–20 credits, ~30 s of network time. On Tavily free tier
94
+ (1k credits/month), that's ~50 plans per month for free.
95
+
96
+ ## Anti-patterns explained
97
+
98
+ **Verbose research summary section**
99
+ A "Research Findings" section dumping every URL with snippet is noise
100
+ — it inflates the plan, distracts the executor, and ages instantly.
101
+ Synthesize: the plan has Decisions with footnotes; that's the
102
+ research's role in the final document.
103
+
104
+ **Aesthetic questions**
105
+ "What color theme?" / "Should we name it FooBar or BarFoo?" — these
106
+ aren't planning questions. Defer to the execution phase.
107
+
108
+ **Single-source citations**
109
+ If every decision footnotes the same URL, the research was shallow.
110
+ Diversify: aim for 1 citation per major source category (vendor docs,
111
+ community blog, security advisory, benchmark, etc.).
112
+
113
+ **Plans without file paths**
114
+ "Update the controller layer" is not a plan, it's a wish. Phase 1
115
+ exists so the plan can say `src/controllers/user.ts:42`.
116
+
117
+ **Surveys (>5 questions)**
118
+ Beyond 5 questions, the user fatigues and starts answering "whatever".
119
+ If you genuinely need more, the task is too big — slice it.
120
+
121
+ ## Plan file conventions
122
+
123
+ - File name: `<slug>-<YYYYMMDD-HHMM>.md`. Slug is kebab-case, ≤50 chars.
124
+ - Slug collision: append `-2`, `-3`, etc.
125
+ - Title in `# Plan: ...` matches the slug.
126
+ - Footnote order: in the order they're first cited.
127
+ - URLs: full https links, no trackers or auth tokens.
128
+ - Code references: `path/to/file.ext:LINE` format, parseable by most IDEs.
129
+
130
+ ## What surf-plan-skill is NOT
131
+
132
+ - Not an execution engine. Once the plan is written, hand it to another
133
+ tool/agent/human.
134
+ - Not a project manager. It's per-task, not multi-task.
135
+ - Not a code generator. It writes a plan, not code.
136
+ - Not a replacement for Plan Mode. Plan Mode is more interactive but
137
+ ephemeral and without web research. They complement.