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,63 @@
1
+ // Generic helpers: flag parsing, string ops, key masking.
2
+
3
+ export function parseFlags(argv) {
4
+ const pos = [];
5
+ const flags = {};
6
+ for (let i = 0; i < argv.length; i++) {
7
+ const a = argv[i];
8
+ if (a.startsWith('--')) {
9
+ const k = a.slice(2);
10
+ const next = argv[i + 1];
11
+ if (!next || next.startsWith('--')) flags[k] = true;
12
+ else { flags[k] = next; i++; }
13
+ } else {
14
+ pos.push(a);
15
+ }
16
+ }
17
+ return { pos, flags };
18
+ }
19
+
20
+ export function clamp(n, min, max) {
21
+ return Math.min(Math.max(n, min), max);
22
+ }
23
+
24
+ export function ceilDiv(a, b) {
25
+ return Math.ceil(a / b);
26
+ }
27
+
28
+ export function splitList(s) {
29
+ return typeof s === 'string' ? s.split(',').map(x => x.trim()).filter(Boolean) : undefined;
30
+ }
31
+
32
+ export function trunc(s, n) {
33
+ if (!s) return '';
34
+ return s.length > n ? s.slice(0, n) + '…' : s;
35
+ }
36
+
37
+ export function flat(v) {
38
+ if (v == null) return '';
39
+ if (typeof v === 'string') return v;
40
+ return flat(v.message) || flat(v.error) || flat(v.detail) || JSON.stringify(v);
41
+ }
42
+
43
+ export function maskKey(key) {
44
+ if (!key || typeof key !== 'string') return '<empty>';
45
+ if (key.length <= 9) return key.slice(0, 2) + '…' + key.slice(-2);
46
+ return key.slice(0, 5) + '…' + key.slice(-4);
47
+ }
48
+
49
+ export function sleep(ms) {
50
+ return new Promise(r => setTimeout(r, ms));
51
+ }
52
+
53
+ export function nowIso() {
54
+ return new Date().toISOString();
55
+ }
56
+
57
+ export function compactObject(obj) {
58
+ const out = {};
59
+ for (const k of Object.keys(obj)) {
60
+ if (obj[k] !== undefined) out[k] = obj[k];
61
+ }
62
+ return out;
63
+ }
@@ -0,0 +1,110 @@
1
+ // Markdown formatters that consume the NORMALIZED result envelope:
2
+ // { provider, operation, data, usage, latency_ms, raw }
3
+
4
+ import { trunc } from './flags.mjs';
5
+
6
+ const MAX_RAW = Number(process.env.SURF_MAX_CONTENT_CHARS || process.env.TAVILY_MAX_CONTENT_CHARS) || 1500;
7
+
8
+ function footer(envelope) {
9
+ const c = envelope.usage && envelope.usage.credits;
10
+ const bits = [`provider: ${envelope.provider}`];
11
+ if (envelope.latency_ms != null) bits.push(`${envelope.latency_ms}ms`);
12
+ if (c != null) bits.push(`credits: ${c}`);
13
+ return `\n_${bits.join(' · ')}_\n`;
14
+ }
15
+
16
+ export function fmtSearch(envelope) {
17
+ const r = envelope.data;
18
+ let md = `# Search: ${r.query || ''}\n\n`;
19
+ if (r.answer) md += `**Answer:** ${r.answer}\n\n`;
20
+ (r.results || []).forEach((it, i) => {
21
+ md += `## [${i + 1}] ${it.title || it.url}\n${it.url}\n`;
22
+ if (it.score != null) md += `*score: ${typeof it.score === 'number' ? it.score.toFixed(2) : it.score}*\n`;
23
+ if (it.published_date) md += `*published: ${it.published_date}*\n`;
24
+ md += `\n${trunc(it.content || '', MAX_RAW)}\n\n`;
25
+ if (it.raw_content) {
26
+ md += `<details><summary>raw</summary>\n\n${trunc(it.raw_content, 3000)}\n\n</details>\n\n`;
27
+ }
28
+ });
29
+ md += footer(envelope);
30
+ return md;
31
+ }
32
+
33
+ export function fmtExtract(envelope) {
34
+ const r = envelope.data;
35
+ let md = '# Extracted content\n\n';
36
+ (r.results || []).forEach((it, i) => {
37
+ md += `## [${i + 1}] ${it.url}\n`;
38
+ if (it.title) md += `**${it.title}**\n`;
39
+ md += `\n${trunc(it.raw_content, 3000)}\n\n`;
40
+ });
41
+ if (r.failed && r.failed.length) {
42
+ md += `\n**Failed:**\n`;
43
+ for (const f of r.failed) md += `- ${f.url} — ${f.reason}\n`;
44
+ }
45
+ md += footer(envelope);
46
+ return md;
47
+ }
48
+
49
+ export function fmtCrawl(envelope) {
50
+ const r = envelope.data;
51
+ let md = `# Crawl: ${r.base_url || ''}\n\n`;
52
+ (r.results || []).forEach((it, i) => {
53
+ md += `## [${i + 1}] ${it.url}\n\n`;
54
+ if (it.raw_content) md += `${trunc(it.raw_content, MAX_RAW)}\n\n`;
55
+ });
56
+ md += footer(envelope);
57
+ return md;
58
+ }
59
+
60
+ export function fmtMap(envelope) {
61
+ const r = envelope.data;
62
+ let md = `# Map: ${r.base_url || ''}\n\n`;
63
+ for (const u of r.urls || []) md += `- ${u}\n`;
64
+ md += footer(envelope);
65
+ return md;
66
+ }
67
+
68
+ export function fmtResearchStart(envelope) {
69
+ const r = envelope.data;
70
+ return [
71
+ `**Research started**`,
72
+ `- request_id: \`${r.request_id}\``,
73
+ `- model: ${r.model || '—'}`,
74
+ `- status: ${r.status}`,
75
+ '',
76
+ `Poll with: \`surf-skill research-poll ${r.request_id}\``,
77
+ footer(envelope),
78
+ ].join('\n');
79
+ }
80
+
81
+ export function fmtResearchPoll(envelope) {
82
+ const r = envelope.data;
83
+ if (r.status !== 'completed') {
84
+ return `Research **${r.status}** (request_id=\`${r.request_id}\`)${r.error ? '\n\nerror: ' + r.error : ''}${footer(envelope)}`;
85
+ }
86
+ let md = `# Research report\n\n${r.content || ''}\n\n`;
87
+ if (r.sources && r.sources.length) {
88
+ md += `## Sources\n`;
89
+ r.sources.forEach((s, i) => { md += `${i + 1}. [${s.title || s.url}](${s.url})\n`; });
90
+ }
91
+ md += footer(envelope);
92
+ return md;
93
+ }
94
+
95
+ export function fmtUsage(envelope) {
96
+ return JSON.stringify(envelope.data, null, 2);
97
+ }
98
+
99
+ export function formatFor(envelope) {
100
+ switch (envelope.operation) {
101
+ case 'search': return fmtSearch(envelope);
102
+ case 'extract': return fmtExtract(envelope);
103
+ case 'crawl': return fmtCrawl(envelope);
104
+ case 'map': return fmtMap(envelope);
105
+ case 'research-start': return fmtResearchStart(envelope);
106
+ case 'research-poll': return fmtResearchPoll(envelope);
107
+ case 'usage': return fmtUsage(envelope);
108
+ default: return JSON.stringify(envelope, null, 2);
109
+ }
110
+ }
@@ -0,0 +1,149 @@
1
+ // Cross-OS skill registration helpers used by postinstall / preuninstall.
2
+ //
3
+ // Strategy:
4
+ // - Symlink the package root into each harness's skill dir
5
+ // (~/.claude/skills/surf-skill, ~/.agents/skills/surf-skill, etc.)
6
+ // - On Windows: try fs.symlink with type='junction' first (no admin needed
7
+ // for directories). If EPERM/ENOSYS, fall back to recursive copy.
8
+ // - Idempotent: re-running fixes stale symlinks, leaves user copies alone.
9
+
10
+ import { existsSync, promises as fs } from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+
14
+ const home = os.homedir();
15
+
16
+ // Harness skill directories (per published docs as of 2026-05).
17
+ // Order: canonical first, then per-harness.
18
+ export const HARNESS_DIRS = [
19
+ path.join(home, '.agents', 'skills'), // OpenCode + GH Copilot CLI canonical
20
+ path.join(home, '.claude', 'skills'), // Claude Code
21
+ path.join(home, '.codex', 'skills'), // OpenAI Codex CLI
22
+ path.join(home, '.pi', 'agent', 'skills'), // Pi Coding Agent
23
+ ];
24
+
25
+ // Legacy names from earlier versions that should be removed on upgrade.
26
+ const LEGACY_NAMES = ['tavily', 'surf', 'tvly'];
27
+
28
+ export async function symlinkOrCopy(target, link) {
29
+ // If link already exists, decide whether to replace it.
30
+ if (existsSync(link)) {
31
+ try {
32
+ const stat = await fs.lstat(link);
33
+ if (stat.isSymbolicLink()) {
34
+ const cur = await fs.readlink(link);
35
+ if (path.resolve(cur) === path.resolve(target)) return { action: 'kept-symlink' };
36
+ await fs.unlink(link);
37
+ } else {
38
+ // User has a non-symlink there (probably their own copy). Leave alone.
39
+ return { action: 'preserved-existing' };
40
+ }
41
+ } catch (e) {
42
+ // lstat failed; assume the path is corrupt — try to remove.
43
+ try { await fs.rm(link, { recursive: true, force: true }); } catch {}
44
+ }
45
+ }
46
+
47
+ // Try symlink first (junction on Windows works without admin).
48
+ try {
49
+ const type = process.platform === 'win32' ? 'junction' : 'dir';
50
+ await fs.symlink(target, link, type);
51
+ return { action: 'symlinked' };
52
+ } catch (e) {
53
+ if (e.code !== 'EPERM' && e.code !== 'ENOSYS' && e.code !== 'EEXIST') {
54
+ throw e;
55
+ }
56
+ // Fallback: recursive copy (Windows without dev mode).
57
+ await fs.cp(target, link, { recursive: true });
58
+ return { action: 'copied' };
59
+ }
60
+ }
61
+
62
+ export async function unlinkIfOurs(link, expectedTarget) {
63
+ if (!existsSync(link)) return false;
64
+ try {
65
+ const stat = await fs.lstat(link);
66
+ if (stat.isSymbolicLink()) {
67
+ const cur = await fs.readlink(link);
68
+ if (path.resolve(cur) === path.resolve(expectedTarget)) {
69
+ await fs.unlink(link);
70
+ return true;
71
+ }
72
+ return false;
73
+ }
74
+ // Non-symlink: likely a user copy. Don't delete.
75
+ return false;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ export async function installSkill(pkgRoot) {
82
+ const results = [];
83
+ for (const dir of HARNESS_DIRS) {
84
+ try {
85
+ await fs.mkdir(dir, { recursive: true });
86
+ const link = path.join(dir, 'surf-skill');
87
+ const r = await symlinkOrCopy(pkgRoot, link);
88
+ results.push({ dir: link, ...r });
89
+ } catch (e) {
90
+ results.push({ dir, action: 'error', error: e.message });
91
+ }
92
+ }
93
+ return results;
94
+ }
95
+
96
+ export async function uninstallSkill(pkgRoot) {
97
+ const results = [];
98
+ for (const dir of HARNESS_DIRS) {
99
+ const link = path.join(dir, 'surf-skill');
100
+ try {
101
+ const removed = await unlinkIfOurs(link, pkgRoot);
102
+ results.push({ dir: link, removed });
103
+ } catch (e) {
104
+ results.push({ dir: link, removed: false, error: e.message });
105
+ }
106
+ }
107
+ return results;
108
+ }
109
+
110
+ export async function cleanupLegacy() {
111
+ const results = [];
112
+ for (const dir of HARNESS_DIRS) {
113
+ for (const name of LEGACY_NAMES) {
114
+ const link = path.join(dir, name);
115
+ // Use lstat (not existsSync) so we also catch broken symlinks pointing
116
+ // at paths that no longer exist (e.g. from a removed prior install).
117
+ try {
118
+ const stat = await fs.lstat(link);
119
+ if (stat.isSymbolicLink()) {
120
+ await fs.unlink(link);
121
+ results.push({ removed: link });
122
+ }
123
+ } catch (e) {
124
+ if (e.code !== 'ENOENT') throw e;
125
+ }
126
+ }
127
+ }
128
+ return results;
129
+ }
130
+
131
+ export async function ensureKeysSkeleton() {
132
+ const cfgDir = path.join(home, '.config', 'surf');
133
+ await fs.mkdir(cfgDir, { recursive: true });
134
+ const file = path.join(cfgDir, 'keys.json');
135
+ if (!existsSync(file)) {
136
+ const skeleton = {
137
+ schema_version: 1,
138
+ tavily: { keys: [], current: 0, burned: [] },
139
+ parallel: { keys: [], current: 0, burned: [] },
140
+ last_ok_provider: null,
141
+ };
142
+ await fs.writeFile(file, JSON.stringify(skeleton, null, 2) + '\n');
143
+ if (process.platform !== 'win32') {
144
+ try { await fs.chmod(file, 0o600); } catch {}
145
+ }
146
+ return { created: file };
147
+ }
148
+ return { existed: file };
149
+ }
@@ -0,0 +1,138 @@
1
+ // `surf-skill keys` subcommands: add, remove, list (status), reset, clear.
2
+
3
+ import { loadState, saveStateAtomic, clearBurned, PROVIDERS, KEYS_FILE } from './state.mjs';
4
+ import { maskKey } from './flags.mjs';
5
+
6
+ function nextResetIso(burnedAt) {
7
+ const d = new Date(burnedAt);
8
+ if (Number.isNaN(d.getTime())) return '—';
9
+ const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1, 0, 0, 0));
10
+ return next.toISOString();
11
+ }
12
+
13
+ function requireProvider(flags, allowAll = false) {
14
+ const p = flags.provider;
15
+ if (!p) {
16
+ if (allowAll && flags.all) return null;
17
+ throw new Error(`--provider <${PROVIDERS.join('|')}> is required`);
18
+ }
19
+ if (!PROVIDERS.includes(p)) {
20
+ throw new Error(`unknown provider '${p}' (valid: ${PROVIDERS.join(', ')})`);
21
+ }
22
+ return p;
23
+ }
24
+
25
+ export async function keysAdd(pos, flags) {
26
+ const provider = requireProvider(flags);
27
+ const key = pos[0];
28
+ if (!key) throw new Error('Usage: surf-skill keys add --provider <name> <key>');
29
+ const state = await loadState();
30
+ if (state[provider].keys.includes(key)) {
31
+ return { provider, added: false, reason: 'already exists', state };
32
+ }
33
+ state[provider].keys.push(key);
34
+ if (state[provider].keys.length === 1) state[provider].current = 0;
35
+ await saveStateAtomic(state);
36
+ return { provider, added: true, index: state[provider].keys.length - 1, state };
37
+ }
38
+
39
+ export async function keysRemove(pos, flags) {
40
+ const provider = requireProvider(flags);
41
+ const target = pos[0];
42
+ if (target == null) throw new Error('Usage: surf-skill keys remove --provider <name> <index|key>');
43
+ const state = await loadState();
44
+ const keys = state[provider].keys;
45
+ let idx = -1;
46
+ if (/^\d+$/.test(String(target))) {
47
+ idx = Number(target);
48
+ } else {
49
+ idx = keys.indexOf(target);
50
+ }
51
+ if (idx < 0 || idx >= keys.length) throw new Error(`no key at '${target}' for provider '${provider}'`);
52
+ keys.splice(idx, 1);
53
+ // adjust current and burned indices
54
+ if (state[provider].current >= keys.length) state[provider].current = 0;
55
+ state[provider].burned = state[provider].burned
56
+ .filter(b => b.index !== idx)
57
+ .map(b => (b.index > idx ? { ...b, index: b.index - 1 } : b));
58
+ await saveStateAtomic(state);
59
+ return { provider, removed: true, index: idx, state };
60
+ }
61
+
62
+ export async function keysList(_pos, flags) {
63
+ const state = await loadState();
64
+ if (flags.json) return { json: true, state };
65
+ const lines = [];
66
+ lines.push(`**Surf keys** (config: \`${KEYS_FILE}\`)`);
67
+ lines.push(`last_ok_provider: \`${state.last_ok_provider || 'none'}\`\n`);
68
+ for (const p of PROVIDERS) {
69
+ const pp = state[p];
70
+ const burnedIdx = new Set(pp.burned.map(b => b.index));
71
+ lines.push(`## ${p} (${pp.keys.length} key${pp.keys.length === 1 ? '' : 's'})`);
72
+ if (!pp.keys.length) {
73
+ lines.push(`_no keys — add with \`surf-skill keys add --provider ${p} <key>\`_\n`);
74
+ continue;
75
+ }
76
+ pp.keys.forEach((k, i) => {
77
+ const flags = [];
78
+ if (i === pp.current) flags.push('current');
79
+ if (burnedIdx.has(i)) flags.push('burned');
80
+ lines.push(`- [${i}] ${maskKey(k)}${flags.length ? ' *(' + flags.join(', ') + ')*' : ''}`);
81
+ });
82
+ if (pp.burned.length) {
83
+ lines.push('');
84
+ lines.push(`**Burned:**`);
85
+ for (const b of pp.burned) {
86
+ lines.push(`- index ${b.index} — reason: ${b.reason}, at ${b.at}, auto-reset on ${nextResetIso(b.at)}`);
87
+ }
88
+ }
89
+ lines.push('');
90
+ }
91
+ return { text: lines.join('\n') };
92
+ }
93
+
94
+ export async function keysReset(_pos, flags) {
95
+ const state = await loadState();
96
+ const provider = flags.provider ? requireProvider(flags) : null;
97
+ clearBurned(state, provider);
98
+ await saveStateAtomic(state);
99
+ return { provider, reset: true, state };
100
+ }
101
+
102
+ export async function keysClear(_pos, flags) {
103
+ if (!flags.yes) {
104
+ const tty = process.stdin && process.stdin.isTTY;
105
+ if (!tty) {
106
+ const err = new Error('non-interactive: pass --yes to confirm destructive clear');
107
+ err.code = 'NEEDS_YES';
108
+ throw err;
109
+ }
110
+ }
111
+ const state = await loadState();
112
+ if (flags.all) {
113
+ for (const p of PROVIDERS) state[p] = { keys: [], current: 0, burned: [] };
114
+ state.last_ok_provider = null;
115
+ } else {
116
+ const provider = requireProvider(flags);
117
+ state[provider] = { keys: [], current: 0, burned: [] };
118
+ if (state.last_ok_provider === provider) state.last_ok_provider = null;
119
+ }
120
+ await saveStateAtomic(state);
121
+ return { cleared: true, state };
122
+ }
123
+
124
+ export async function runKeysSubcommand(sub, pos, flags) {
125
+ switch (sub) {
126
+ case 'add': return keysAdd(pos, flags);
127
+ case 'remove':
128
+ case 'rm':
129
+ case 'delete': return keysRemove(pos, flags);
130
+ case 'list':
131
+ case 'ls':
132
+ case 'status': return keysList(pos, flags);
133
+ case 'reset': return keysReset(pos, flags);
134
+ case 'clear': return keysClear(pos, flags);
135
+ default:
136
+ throw new Error(`unknown 'surf-skill keys' subcommand: '${sub}'. Valid: add, remove, list, reset, clear`);
137
+ }
138
+ }
@@ -0,0 +1,81 @@
1
+ // Progress logger — writes one self-contained line per event to stderr.
2
+ //
3
+ // Design constraints (researched 2026-05-20):
4
+ // - stdout stays clean (the LLM/pipe parses JSON or Markdown there).
5
+ // - stderr is line-based, unbuffered, plain text. NO ANSI animation,
6
+ // NO `\r` rewrites — those become noise in non-TTY captures and
7
+ // burn tokens when the agent reads back the stderr at the end of
8
+ // the bash call.
9
+ // - Each line is self-contained: `[surf HH:MM:SS] SYMBOL message`.
10
+ // Agents can grep these lines; humans can read them.
11
+ // - `SURF_QUIET=1` env or setSilent(true) silences output (for tests
12
+ // and scripts that capture stderr).
13
+ //
14
+ // Symbols (Unicode, fits any terminal):
15
+ // ▸ start of an operation/attempt
16
+ // ✓ success
17
+ // ✗ failure
18
+ // ↻ retry / backoff
19
+ // ⓘ informational
20
+ // ⚠ warning / soft issue (e.g. key burned)
21
+ // ⏱ timing / summary
22
+
23
+ import { stderr } from 'node:process';
24
+
25
+ const SYMBOLS = {
26
+ start: '▸',
27
+ success: '✓',
28
+ fail: '✗',
29
+ retry: '↻',
30
+ info: 'ⓘ',
31
+ warn: '⚠',
32
+ done: '⏱',
33
+ };
34
+
35
+ let silent = process.env.SURF_QUIET === '1';
36
+
37
+ export function setSilent(v) {
38
+ silent = !!v;
39
+ }
40
+
41
+ export function isSilent() {
42
+ return silent;
43
+ }
44
+
45
+ function ts() {
46
+ const d = new Date();
47
+ const hh = String(d.getUTCHours()).padStart(2, '0');
48
+ const mm = String(d.getUTCMinutes()).padStart(2, '0');
49
+ const ss = String(d.getUTCSeconds()).padStart(2, '0');
50
+ return `${hh}:${mm}:${ss}`;
51
+ }
52
+
53
+ function write(symbolKey, msg) {
54
+ if (silent) return;
55
+ const sym = SYMBOLS[symbolKey] || '·';
56
+ stderr.write(`[surf ${ts()}] ${sym} ${msg}\n`);
57
+ }
58
+
59
+ export const progress = {
60
+ start: (msg) => write('start', msg),
61
+ success: (msg) => write('success', msg),
62
+ fail: (msg) => write('fail', msg),
63
+ retry: (msg) => write('retry', msg),
64
+ info: (msg) => write('info', msg),
65
+ warn: (msg) => write('warn', msg),
66
+ done: (msg) => write('done', msg),
67
+ };
68
+
69
+ // Convenience: time an async block. Emits start/done with elapsed.
70
+ export async function timed(label, fn) {
71
+ const t0 = Date.now();
72
+ progress.start(label);
73
+ try {
74
+ const r = await fn();
75
+ progress.done(`${label} (${Date.now() - t0}ms)`);
76
+ return r;
77
+ } catch (e) {
78
+ progress.fail(`${label} (${Date.now() - t0}ms): ${e.message || e}`);
79
+ throw e;
80
+ }
81
+ }
@@ -0,0 +1,145 @@
1
+ // `surf-skill project-config` — writes per-project harness config to raise
2
+ // the bash timeout that the harness uses. Detects which harness is in use
3
+ // from the presence of `.github/`, `.claude/`, `.pi/` in the cwd. With
4
+ // --harness, forces a specific target.
5
+
6
+ import { existsSync } from 'node:fs';
7
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ import readline from 'node:readline/promises';
10
+ import { stdin, stdout } from 'node:process';
11
+
12
+ export const HARNESSES = ['copilot', 'claude', 'pi'];
13
+
14
+ const PATCHES = {
15
+ copilot: {
16
+ file: '.github/copilot-hooks.json',
17
+ patch: { timeoutSec: 300 },
18
+ why: 'GH Copilot CLI default bash timeout is 30s — surf-skill needs more.',
19
+ },
20
+ claude: {
21
+ // .claude/settings.local.json is gitignored by Anthropic convention.
22
+ file: '.claude/settings.local.json',
23
+ patch: {
24
+ env: {
25
+ BASH_DEFAULT_TIMEOUT_MS: '300000',
26
+ BASH_MAX_TIMEOUT_MS: '600000',
27
+ },
28
+ },
29
+ why: 'Claude Code default bash timeout is 120s; raising to 300s.',
30
+ },
31
+ pi: {
32
+ file: '.pi/settings.json',
33
+ patch: {
34
+ env: {
35
+ PI_BASH_DEFAULT_TIMEOUT_SECONDS: '300',
36
+ PI_BASH_MAX_TIMEOUT_SECONDS: '600',
37
+ },
38
+ },
39
+ why: 'Pi Coding Agent default bash timeout is 120s; raising to 300s.',
40
+ },
41
+ };
42
+
43
+ function isPlainObject(v) {
44
+ return v && typeof v === 'object' && !Array.isArray(v);
45
+ }
46
+
47
+ function deepMerge(target, source) {
48
+ if (!isPlainObject(target)) return source;
49
+ if (!isPlainObject(source)) return source;
50
+ for (const k of Object.keys(source)) {
51
+ if (isPlainObject(source[k])) {
52
+ target[k] = deepMerge(isPlainObject(target[k]) ? target[k] : {}, source[k]);
53
+ } else {
54
+ target[k] = source[k];
55
+ }
56
+ }
57
+ return target;
58
+ }
59
+
60
+ async function mergeJsonFile(absPath, patch) {
61
+ await mkdir(dirname(absPath), { recursive: true });
62
+ let current = {};
63
+ if (existsSync(absPath)) {
64
+ try { current = JSON.parse(await readFile(absPath, 'utf8')); } catch { current = {}; }
65
+ }
66
+ const merged = deepMerge(current, patch);
67
+ await writeFile(absPath, JSON.stringify(merged, null, 2) + '\n');
68
+ return { wrote: absPath, patch, merged };
69
+ }
70
+
71
+ export function detectHarnesses(cwd) {
72
+ const found = [];
73
+ if (existsSync(join(cwd, '.github'))) found.push('copilot');
74
+ if (existsSync(join(cwd, '.claude'))) found.push('claude');
75
+ if (existsSync(join(cwd, '.pi'))) found.push('pi');
76
+ return found;
77
+ }
78
+
79
+ async function promptHarness() {
80
+ if (!stdin.isTTY) {
81
+ const err = new Error(
82
+ "project-config could not detect any harness directory and stdin is not a TTY. " +
83
+ "Pass --harness <copilot|claude|pi|all>."
84
+ );
85
+ err.code = 'PROJECT_CONFIG_NO_TTY';
86
+ throw err;
87
+ }
88
+ const rl = readline.createInterface({ input: stdin, output: stdout });
89
+ try {
90
+ const a = (await rl.question(
91
+ 'No harness directory detected here. Which harness? [copilot/claude/pi/all]: '
92
+ )).trim().toLowerCase();
93
+ if (a === 'all') return [...HARNESSES];
94
+ if (HARNESSES.includes(a)) return [a];
95
+ throw Object.assign(new Error(`unknown harness '${a}'`), { code: 'PROJECT_CONFIG_BAD_HARNESS' });
96
+ } finally {
97
+ rl.close();
98
+ }
99
+ }
100
+
101
+ export async function runProjectConfig(_pos, flags = {}, cwd = process.cwd()) {
102
+ let targets;
103
+ if (flags.harness) {
104
+ if (flags.harness === 'all') {
105
+ targets = [...HARNESSES];
106
+ } else if (HARNESSES.includes(flags.harness)) {
107
+ targets = [flags.harness];
108
+ } else {
109
+ throw Object.assign(
110
+ new Error(`unknown --harness '${flags.harness}'; valid: ${HARNESSES.join(', ')}, all`),
111
+ { code: 'PROJECT_CONFIG_BAD_HARNESS' }
112
+ );
113
+ }
114
+ } else {
115
+ const detected = detectHarnesses(cwd);
116
+ targets = detected.length ? detected : await promptHarness();
117
+ }
118
+
119
+ const results = [];
120
+ for (const t of targets) {
121
+ const spec = PATCHES[t];
122
+ const abs = resolve(cwd, spec.file);
123
+ const r = await mergeJsonFile(abs, spec.patch);
124
+ results.push({ harness: t, file: r.wrote, why: spec.why });
125
+ }
126
+
127
+ return { cwd, targets, results };
128
+ }
129
+
130
+ export function formatProjectConfigResult(result, { json = false } = {}) {
131
+ if (json) return JSON.stringify(result, null, 2);
132
+ const lines = [`✓ surf-skill project-config in ${result.cwd}`];
133
+ for (const r of result.results) {
134
+ lines.push(` • ${r.harness}: wrote ${r.file}`);
135
+ lines.push(` ${r.why}`);
136
+ }
137
+ lines.push('');
138
+ if (result.targets.includes('copilot')) {
139
+ lines.push('ℹ Commit .github/copilot-hooks.json so teammates inherit the timeout.');
140
+ }
141
+ if (result.targets.includes('claude')) {
142
+ lines.push('ℹ .claude/settings.local.json is .gitignored by convention (per-user).');
143
+ }
144
+ return lines.join('\n');
145
+ }