@apmantza/greedysearch-pi 1.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/index.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * GreedySearch Pi Extension
3
+ *
4
+ * Adds a `greedy_search` tool to Pi that fans out queries to Perplexity,
5
+ * Bing Copilot, and Google AI in parallel, returning synthesized AI answers.
6
+ *
7
+ * Requires Chrome to be running (or it auto-launches a dedicated instance).
8
+ */
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { existsSync } from "node:fs";
12
+ import { join, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15
+ import { Type } from "@sinclair/typebox";
16
+
17
+ const __dir = dirname(fileURLToPath(import.meta.url));
18
+
19
+ function cdpAvailable(): boolean {
20
+ return existsSync(join(__dir, "cdp.mjs"));
21
+ }
22
+
23
+ function runSearch(engine: string, query: string): Promise<Record<string, unknown>> {
24
+ return new Promise((resolve, reject) => {
25
+ const proc = spawn("node", [__dir + "/search.mjs", engine, query], {
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ });
28
+ let out = "";
29
+ let err = "";
30
+ proc.stdout.on("data", (d: Buffer) => (out += d));
31
+ proc.stderr.on("data", (d: Buffer) => (err += d));
32
+ proc.on("close", (code: number) => {
33
+ if (code !== 0) {
34
+ reject(new Error(err.trim() || `search.mjs exited with code ${code}`));
35
+ } else {
36
+ try {
37
+ resolve(JSON.parse(out.trim()));
38
+ } catch {
39
+ reject(new Error(`Invalid JSON from search.mjs: ${out.slice(0, 200)}`));
40
+ }
41
+ }
42
+ });
43
+ });
44
+ }
45
+
46
+ function formatResults(engine: string, data: Record<string, unknown>): string {
47
+ const lines: string[] = [];
48
+
49
+ if (engine === "all") {
50
+ for (const [eng, result] of Object.entries(data)) {
51
+ if (eng.startsWith("_")) continue;
52
+ lines.push(`\n## ${eng.charAt(0).toUpperCase() + eng.slice(1)}`);
53
+ const r = result as Record<string, unknown>;
54
+ if (r.error) {
55
+ lines.push(`Error: ${r.error}`);
56
+ } else {
57
+ if (r.answer) lines.push(String(r.answer));
58
+ if (Array.isArray(r.sources) && r.sources.length > 0) {
59
+ lines.push("\nSources:");
60
+ for (const s of r.sources.slice(0, 3)) {
61
+ const src = s as Record<string, string>;
62
+ lines.push(`- [${src.title || src.url}](${src.url})`);
63
+ }
64
+ }
65
+ }
66
+ }
67
+ } else {
68
+ if (data.error) {
69
+ lines.push(`Error: ${data.error}`);
70
+ } else {
71
+ if (data.answer) lines.push(String(data.answer));
72
+ if (Array.isArray(data.sources) && data.sources.length > 0) {
73
+ lines.push("\nSources:");
74
+ for (const s of data.sources.slice(0, 5)) {
75
+ const src = s as Record<string, string>;
76
+ lines.push(`- [${src.title || src.url}](${src.url})`);
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ return lines.join("\n").trim();
83
+ }
84
+
85
+ export default function greedySearchExtension(pi: ExtensionAPI) {
86
+ pi.on("session_start", async (_event, ctx) => {
87
+ if (!cdpAvailable()) {
88
+ ctx.ui.notify(
89
+ "GreedySearch: cdp.mjs missing from package directory — try reinstalling: pi install git:github.com/apmantza/GreedySearch-pi",
90
+ "warning",
91
+ );
92
+ }
93
+ });
94
+
95
+ pi.registerTool({
96
+ name: "greedy_search",
97
+ label: "Greedy Search",
98
+ description:
99
+ "Search the web using AI-powered engines (Perplexity, Bing Copilot, Google AI) in parallel. " +
100
+ "Returns synthesized AI answers with sources. Use for current information, library docs, " +
101
+ "error messages, best practices, or any question where training data may be stale.",
102
+ parameters: Type.Object({
103
+ query: Type.String({ description: "The search query" }),
104
+ engine: Type.Union(
105
+ [
106
+ Type.Literal("all"),
107
+ Type.Literal("perplexity"),
108
+ Type.Literal("bing"),
109
+ Type.Literal("google"),
110
+ ],
111
+ {
112
+ description: 'Engine to use. "all" fans out to all three in parallel (default).',
113
+ default: "all",
114
+ },
115
+ ),
116
+ }),
117
+ execute: async (_toolCallId, params) => {
118
+ const { query, engine = "all" } = params as { query: string; engine: string };
119
+
120
+ if (!cdpAvailable()) {
121
+ return {
122
+ content: [{ type: "text", text: "cdp.mjs missing — try reinstalling: pi install git:github.com/apmantza/GreedySearch-pi" }],
123
+ details: {} as { raw?: Record<string, unknown> },
124
+ };
125
+ }
126
+
127
+ let data: Record<string, unknown>;
128
+ try {
129
+ data = await runSearch(engine, query);
130
+ } catch (e) {
131
+ const msg = e instanceof Error ? e.message : String(e);
132
+ return {
133
+ content: [{ type: "text", text: `Search failed: ${msg}` }],
134
+ details: {} as { raw?: Record<string, unknown> },
135
+ };
136
+ }
137
+
138
+ const text = formatResults(engine, data);
139
+ return {
140
+ content: [{ type: "text", text: text || "No results returned." }],
141
+ details: { raw: data } as { raw?: Record<string, unknown> },
142
+ };
143
+ },
144
+ });
145
+ }
package/launch.mjs ADDED
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ // launch.mjs — start a dedicated Chrome instance for GreedySearch
3
+ //
4
+ // This Chrome instance uses --disable-features=DevToolsPrivacyUI which suppresses
5
+ // the "Allow remote debugging?" dialog entirely. It runs on port 9223 so it doesn't
6
+ // conflict with your main Chrome session.
7
+ //
8
+ // On launch, it overwrites the DevToolsActivePort file that cdp.mjs reads so all
9
+ // extractors automatically target the GreedySearch Chrome, with no code changes.
10
+ // The original file is restored on --kill.
11
+ //
12
+ // Usage:
13
+ // node launch.mjs — launch (or report if already running)
14
+ // node launch.mjs --kill — stop and restore original DevToolsActivePort
15
+ // node launch.mjs --status — check if running
16
+
17
+ import { spawn } from 'child_process';
18
+ import { existsSync, writeFileSync, readFileSync, copyFileSync, mkdirSync, unlinkSync } from 'fs';
19
+ import { tmpdir, homedir, platform } from 'os';
20
+ import { join } from 'path';
21
+ import http from 'http';
22
+
23
+ const PORT = 9223;
24
+ const PROFILE_DIR = join(tmpdir(), 'greedysearch-chrome-profile');
25
+ const ACTIVE_PORT = join(PROFILE_DIR, 'DevToolsActivePort');
26
+ const PID_FILE = join(tmpdir(), 'greedysearch-chrome.pid');
27
+
28
+ function findChrome() {
29
+ const os = platform();
30
+ const candidates = os === 'win32' ? [
31
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
32
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
33
+ ] : os === 'darwin' ? [
34
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
35
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
36
+ ] : [
37
+ '/usr/bin/google-chrome',
38
+ '/usr/bin/google-chrome-stable',
39
+ '/usr/bin/chromium-browser',
40
+ '/usr/bin/chromium',
41
+ '/snap/bin/chromium',
42
+ ];
43
+ return candidates.find(existsSync) || null;
44
+ }
45
+
46
+ function systemPortPath() {
47
+ const os = platform();
48
+ if (os === 'win32') return join(homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'User Data', 'DevToolsActivePort');
49
+ if (os === 'darwin') return join(homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort');
50
+ return join(homedir(), '.config', 'google-chrome', 'DevToolsActivePort');
51
+ }
52
+
53
+ const SYSTEM_PORT = systemPortPath();
54
+ const SYSTEM_BACKUP = SYSTEM_PORT + '.bak';
55
+
56
+ const CHROME_FLAGS = [
57
+ `--remote-debugging-port=${PORT}`,
58
+ '--disable-features=DevToolsPrivacyUI', // suppresses "Allow remote debugging?" dialog
59
+ '--no-first-run',
60
+ '--no-default-browser-check',
61
+ '--disable-default-apps',
62
+ `--user-data-dir=${PROFILE_DIR}`,
63
+ '--profile-directory=Default',
64
+ 'about:blank',
65
+ ];
66
+
67
+ // ---------------------------------------------------------------------------
68
+
69
+
70
+ function isRunning() {
71
+ if (!existsSync(PID_FILE)) return false;
72
+ const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim());
73
+ if (!pid) return false;
74
+ try { process.kill(pid, 0); return pid; } catch { return false; }
75
+ }
76
+
77
+ function httpGet(url, timeoutMs = 1000) {
78
+ return new Promise(resolve => {
79
+ const req = http.get(url, res => {
80
+ let body = '';
81
+ res.on('data', d => body += d);
82
+ res.on('end', () => resolve({ ok: res.statusCode === 200, body }));
83
+ });
84
+ req.on('error', () => resolve({ ok: false }));
85
+ req.setTimeout(timeoutMs, () => { req.destroy(); resolve({ ok: false }); });
86
+ });
87
+ }
88
+
89
+
90
+ async function writePortFile(timeoutMs = 15000) {
91
+ // Chrome on Windows doesn't write DevToolsActivePort — we build it from the HTTP API.
92
+ const deadline = Date.now() + timeoutMs;
93
+ while (Date.now() < deadline) {
94
+ const { ok, body } = await httpGet(`http://localhost:${PORT}/json/version`, 1500);
95
+ if (ok) {
96
+ try {
97
+ const { webSocketDebuggerUrl } = JSON.parse(body);
98
+ // webSocketDebuggerUrl = "ws://localhost:9223/devtools/browser/..."
99
+ const wsPath = new URL(webSocketDebuggerUrl).pathname;
100
+ // Write in DevToolsActivePort format: port on line 1, path on line 2
101
+ const content = `${PORT}\n${wsPath}`;
102
+ writeFileSync(ACTIVE_PORT, content, 'utf8');
103
+ return true;
104
+ } catch { /* malformed response, retry */ }
105
+ }
106
+ await new Promise(r => setTimeout(r, 400));
107
+ }
108
+ return false;
109
+ }
110
+
111
+ function redirectCdpToGreedySearch() {
112
+ // Back up system DevToolsActivePort (user's main Chrome)
113
+ if (existsSync(SYSTEM_PORT) && !existsSync(SYSTEM_BACKUP)) {
114
+ copyFileSync(SYSTEM_PORT, SYSTEM_BACKUP);
115
+ }
116
+ // Point cdp.mjs to our dedicated Chrome's port
117
+ copyFileSync(ACTIVE_PORT, SYSTEM_PORT);
118
+ }
119
+
120
+ function restoreCdpToMainChrome() {
121
+ if (existsSync(SYSTEM_BACKUP)) {
122
+ copyFileSync(SYSTEM_BACKUP, SYSTEM_PORT);
123
+ console.log('Restored DevToolsActivePort to main Chrome.');
124
+ } else if (existsSync(SYSTEM_PORT)) {
125
+ // No backup means main Chrome wasn't using CDP — remove our file
126
+ try { unlinkSync(SYSTEM_PORT); } catch {}
127
+ }
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+
132
+ async function main() {
133
+ const arg = process.argv[2];
134
+
135
+ if (arg === '--kill') {
136
+ const pid = isRunning();
137
+ if (pid) {
138
+ try { process.kill(pid, 'SIGTERM'); console.log(`Stopped Chrome (pid ${pid}).`); }
139
+ catch (e) { console.error(`Failed: ${e.message}`); }
140
+ } else {
141
+ console.log('GreedySearch Chrome is not running.');
142
+ }
143
+ restoreCdpToMainChrome();
144
+ return;
145
+ }
146
+
147
+ if (arg === '--status') {
148
+ const pid = isRunning();
149
+ if (pid) console.log(`Running — pid ${pid}, port ${PORT}, DevToolsActivePort redirected.`);
150
+ else console.log('Not running.');
151
+ return;
152
+ }
153
+
154
+ // Already running?
155
+ const existing = isRunning();
156
+ if (existing) {
157
+ console.log(`GreedySearch Chrome already running (pid ${existing}, port ${PORT}).`);
158
+ // Ensure redirect is in place (idempotent)
159
+ const ready = await writePortFile(5000);
160
+ if (ready) { redirectCdpToGreedySearch(); console.log('DevToolsActivePort redirected.'); }
161
+ return;
162
+ }
163
+
164
+ const CHROME_EXE = process.env.CHROME_PATH || findChrome();
165
+ if (!CHROME_EXE) {
166
+ console.error('Chrome not found. Tried standard paths for your OS.');
167
+ console.error('Set the CHROME_PATH environment variable to point to your Chrome binary.');
168
+ process.exit(1);
169
+ }
170
+
171
+ mkdirSync(PROFILE_DIR, { recursive: true });
172
+
173
+ console.log(`Launching GreedySearch Chrome on port ${PORT}...`);
174
+ const proc = spawn(CHROME_EXE, CHROME_FLAGS, {
175
+ detached: true,
176
+ stdio: 'ignore',
177
+ windowsHide: false,
178
+ });
179
+ proc.unref();
180
+ writeFileSync(PID_FILE, String(proc.pid));
181
+
182
+ // Wait for Chrome HTTP endpoint, build DevToolsActivePort file, redirect cdp.mjs
183
+ const portFileReady = await writePortFile();
184
+ if (!portFileReady) {
185
+ console.error('Chrome did not become ready within 15s.');
186
+ process.exit(1);
187
+ }
188
+ redirectCdpToGreedySearch();
189
+
190
+ console.log(`Ready. No more "Allow remote debugging?" dialogs.`);
191
+ console.log(`Run "node launch.mjs --kill" when done to restore your main Chrome's CDP.`);
192
+ }
193
+
194
+ main();
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@apmantza/greedysearch-pi",
3
+ "version": "1.0.0",
4
+ "description": "Pi extension: search Perplexity, Bing Copilot, and Google AI simultaneously — synthesized AI answers, not just links",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/apmantza/GreedySearch-pi"
12
+ },
13
+ "license": "MIT",
14
+ "pi": {
15
+ "extensions": [
16
+ "./index.ts"
17
+ ],
18
+ "skills": [
19
+ "./skills"
20
+ ]
21
+ },
22
+ "peerDependencies": {
23
+ "@mariozechner/pi-coding-agent": "*",
24
+ "@sinclair/typebox": "*"
25
+ }
26
+ }
package/search.mjs ADDED
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ // search.mjs — unified CLI for GreedySearch extractors
3
+ //
4
+ // Usage:
5
+ // node search.mjs <engine> "<query>"
6
+ // node search.mjs all "<query>"
7
+ //
8
+ // Engines:
9
+ // perplexity | pplx | p
10
+ // bing | copilot | b
11
+ // google | g
12
+ // stackoverflow | so | stack
13
+ // all — fan-out to all engines in parallel
14
+ //
15
+ // Output: JSON to stdout, errors to stderr
16
+ //
17
+ // Examples:
18
+ // node search.mjs p "what is memoization"
19
+ // node search.mjs so "node.js event loop explained"
20
+ // node search.mjs all "how does TCP congestion control work"
21
+
22
+ import { spawn } from 'child_process';
23
+ import { fileURLToPath } from 'url';
24
+ import { join, dirname } from 'path';
25
+ import { readFileSync, existsSync, writeFileSync } from 'fs';
26
+ import { tmpdir } from 'os';
27
+
28
+ const __dir = dirname(fileURLToPath(import.meta.url));
29
+ // Pi installs chrome-cdp-skill to ~/.pi/agent/git/...; fall back to Claude Code path
30
+ // Always use the bundled Windows-compatible cdp.mjs
31
+ const CDP = join(__dir, 'cdp.mjs');
32
+ const PAGES_CACHE = `${tmpdir().replace(/\\/g, '/')}/cdp-pages.json`;
33
+
34
+ const ENGINES = {
35
+ perplexity: 'perplexity.mjs',
36
+ pplx: 'perplexity.mjs',
37
+ p: 'perplexity.mjs',
38
+ bing: 'bing-copilot.mjs',
39
+ copilot: 'bing-copilot.mjs',
40
+ b: 'bing-copilot.mjs',
41
+ google: 'google-ai.mjs',
42
+ g: 'google-ai.mjs',
43
+ stackoverflow: 'stackoverflow-ai.mjs',
44
+ so: 'stackoverflow-ai.mjs',
45
+ stack: 'stackoverflow-ai.mjs',
46
+ };
47
+
48
+ const ALL_ENGINES = ['perplexity', 'bing', 'google']; // stackoverflow: disabled until polling fix
49
+
50
+ const ENGINE_DOMAINS = {
51
+ perplexity: 'perplexity.ai',
52
+ bing: 'copilot.microsoft.com',
53
+ google: 'google.com',
54
+ stackoverflow: 'stackoverflow.com',
55
+ };
56
+
57
+ function getTabFromCache(engine) {
58
+ try {
59
+ if (!existsSync(PAGES_CACHE)) return null;
60
+ const pages = JSON.parse(readFileSync(PAGES_CACHE, 'utf8'));
61
+ const found = pages.find(p => p.url.includes(ENGINE_DOMAINS[engine]));
62
+ return found ? found.targetId.slice(0, 8) : null;
63
+ } catch { return null; }
64
+ }
65
+
66
+ function cdp(args, timeoutMs = 15000) {
67
+ return new Promise((resolve, reject) => {
68
+ const proc = spawn('node', [CDP, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
69
+ let out = '', err = '';
70
+ proc.stdout.on('data', d => out += d);
71
+ proc.stderr.on('data', d => err += d);
72
+ const t = setTimeout(() => { proc.kill(); reject(new Error(`cdp timeout: ${args[0]}`)); }, timeoutMs);
73
+ proc.on('close', code => {
74
+ clearTimeout(t);
75
+ if (code !== 0) reject(new Error(err.trim() || `cdp exit ${code}`));
76
+ else resolve(out.trim());
77
+ });
78
+ });
79
+ }
80
+
81
+ async function getAnyTab() {
82
+ const list = await cdp(['list']);
83
+ const first = list.split('\n')[0];
84
+ if (!first) throw new Error('No Chrome tabs found');
85
+ return first.slice(0, 8);
86
+ }
87
+
88
+ async function getOrReuseBlankTab() {
89
+ // Reuse an existing about:blank tab rather than always creating a new one
90
+ const listOut = await cdp(['list']);
91
+ const lines = listOut.split('\n').filter(Boolean);
92
+ for (const line of lines) {
93
+ if (line.includes('about:blank')) {
94
+ return line.slice(0, 8); // prefix of the blank tab's targetId
95
+ }
96
+ }
97
+ // No blank tab — open a new one
98
+ const anchor = await getAnyTab();
99
+ const raw = await cdp(['evalraw', anchor, 'Target.createTarget', '{"url":"about:blank"}']);
100
+ const { targetId } = JSON.parse(raw);
101
+ return targetId;
102
+ }
103
+
104
+ async function openNewTab() {
105
+ const anchor = await getAnyTab();
106
+ const raw = await cdp(['evalraw', anchor, 'Target.createTarget', '{"url":"about:blank"}']);
107
+ const { targetId } = JSON.parse(raw);
108
+ return targetId;
109
+ }
110
+
111
+ async function closeTab(targetId) {
112
+ try {
113
+ const anchor = await getAnyTab();
114
+ await cdp(['evalraw', anchor, 'Target.closeTarget', JSON.stringify({ targetId })]);
115
+ } catch { /* best-effort */ }
116
+ }
117
+
118
+ function runExtractor(script, query, tabPrefix = null, short = false) {
119
+ const extraArgs = [
120
+ ...(tabPrefix ? ['--tab', tabPrefix] : []),
121
+ ...(short ? ['--short'] : []),
122
+ ];
123
+ return new Promise((resolve, reject) => {
124
+ const proc = spawn('node', [join(__dir, 'extractors', script), query, ...extraArgs], {
125
+ stdio: ['ignore', 'pipe', 'pipe'],
126
+ });
127
+ let out = '';
128
+ let err = '';
129
+ proc.stdout.on('data', d => out += d);
130
+ proc.stderr.on('data', d => err += d);
131
+ proc.on('close', code => {
132
+ if (code !== 0) reject(new Error(err.trim() || `extractor exit ${code}`));
133
+ else {
134
+ try { resolve(JSON.parse(out.trim())); }
135
+ catch { reject(new Error(`bad JSON from ${script}: ${out.slice(0, 100)}`)); }
136
+ }
137
+ });
138
+ });
139
+ }
140
+
141
+
142
+ async function fetchTopSource(url) {
143
+ const tab = await openNewTab();
144
+ await cdp(['list']); // refresh cache so the new tab is findable
145
+ try {
146
+ await cdp(['nav', tab, url], 30000);
147
+ await new Promise(r => setTimeout(r, 1500));
148
+ const content = await cdp(['eval', tab, `
149
+ (function(){
150
+ var el = document.querySelector('article, [role="main"], main, .post-content, .article-body, #content, .content');
151
+ var text = (el || document.body).innerText;
152
+ return text.replace(/\\s+/g, ' ').trim().slice(0, 1500);
153
+ })()
154
+ `]);
155
+ return { url, content };
156
+ } catch (e) {
157
+ return { url, content: null, error: e.message };
158
+ } finally {
159
+ await closeTab(tab);
160
+ }
161
+ }
162
+
163
+ function pickTopSource(out) {
164
+ for (const engine of ['perplexity', 'google', 'bing']) {
165
+ const r = out[engine];
166
+ if (r?.sources?.length > 0) return r.sources[0];
167
+ }
168
+ return null;
169
+ }
170
+
171
+ function writeOutput(data, outFile) {
172
+ const json = JSON.stringify(data, null, 2) + '\n';
173
+ if (outFile) {
174
+ writeFileSync(outFile, json, 'utf8');
175
+ process.stderr.write(`Results written to ${outFile}\n`);
176
+ } else {
177
+ process.stdout.write(json);
178
+ }
179
+ }
180
+
181
+ async function ensureChrome() {
182
+ try {
183
+ await cdp(['list'], 3000);
184
+ } catch {
185
+ process.stderr.write('Chrome not running — auto-launching GreedySearch Chrome...\n');
186
+ await new Promise((resolve, reject) => {
187
+ const proc = spawn('node', [join(__dir, 'launch.mjs')], { stdio: 'inherit' });
188
+ proc.on('close', code => code === 0 ? resolve() : reject(new Error('launch.mjs failed')));
189
+ });
190
+ }
191
+ }
192
+
193
+ async function main() {
194
+ const args = process.argv.slice(2);
195
+ if (args.length < 2 || args[0] === '--help') {
196
+ process.stderr.write([
197
+ 'Usage: node search.mjs <engine> "<query>"',
198
+ '',
199
+ 'Engines: perplexity (p), bing (b), google (g), stackoverflow (so), all',
200
+ '',
201
+ 'Examples:',
202
+ ' node search.mjs p "what is memoization"',
203
+ ' node search.mjs so "node.js event loop explained"',
204
+ ' node search.mjs all "TCP congestion control"',
205
+ ].join('\n') + '\n');
206
+ process.exit(1);
207
+ }
208
+
209
+ await ensureChrome();
210
+
211
+ const short = args.includes('--short');
212
+ const fetchSource = args.includes('--fetch-top-source');
213
+ const outIdx = args.indexOf('--out');
214
+ const outFile = outIdx !== -1 ? args[outIdx + 1] : null;
215
+ const rest = args.filter((a, i) =>
216
+ a !== '--short' &&
217
+ a !== '--fetch-top-source' &&
218
+ a !== '--out' &&
219
+ (outIdx === -1 || i !== outIdx + 1)
220
+ );
221
+ const engine = rest[0].toLowerCase();
222
+ const query = rest.slice(1).join(' ');
223
+
224
+ if (engine === 'all') {
225
+ await cdp(['list']); // refresh pages cache
226
+
227
+ // Assign tabs: reuse existing engine tabs from cache, open new ones only where needed.
228
+ // Track opened tabs separately so we only close what we created.
229
+ const tabs = [];
230
+ const openedTabs = [];
231
+ let blankReused = false;
232
+
233
+ for (const e of ALL_ENGINES) {
234
+ const existing = getTabFromCache(e);
235
+ if (existing) {
236
+ tabs.push(existing);
237
+ } else if (!blankReused) {
238
+ const tab = await getOrReuseBlankTab();
239
+ tabs.push(tab);
240
+ openedTabs.push(tab);
241
+ blankReused = true;
242
+ } else {
243
+ await new Promise(r => setTimeout(r, 500));
244
+ const tab = await openNewTab();
245
+ tabs.push(tab);
246
+ openedTabs.push(tab);
247
+ }
248
+ }
249
+
250
+ // All tabs assigned — run extractors in parallel
251
+ const results = await Promise.allSettled(
252
+ ALL_ENGINES.map((e, i) =>
253
+ runExtractor(ENGINES[e], query, tabs[i], short).then(r => ({ engine: e, ...r }))
254
+ )
255
+ );
256
+
257
+ // Close only tabs we opened (not pre-existing ones)
258
+ await Promise.allSettled(openedTabs.map(closeTab));
259
+
260
+ const out = {};
261
+ for (let i = 0; i < results.length; i++) {
262
+ const r = results[i];
263
+ if (r.status === 'fulfilled') {
264
+ out[r.value.engine] = r.value;
265
+ } else {
266
+ out[ALL_ENGINES[i]] = { error: r.reason?.message || 'unknown error' };
267
+ }
268
+ }
269
+
270
+ if (fetchSource) {
271
+ const top = pickTopSource(out);
272
+ if (top) out._topSource = await fetchTopSource(top.url);
273
+ }
274
+
275
+ writeOutput(out, outFile);
276
+ return;
277
+ }
278
+
279
+ const script = ENGINES[engine];
280
+ if (!script) {
281
+ process.stderr.write(`Unknown engine: "${engine}"\nAvailable: ${Object.keys(ENGINES).join(', ')}\n`);
282
+ process.exit(1);
283
+ }
284
+
285
+ try {
286
+ const result = await runExtractor(script, query, null, short);
287
+ if (fetchSource && result.sources?.length > 0) {
288
+ result.topSource = await fetchTopSource(result.sources[0].url);
289
+ }
290
+ writeOutput(result, outFile);
291
+ } catch (e) {
292
+ process.stderr.write(`Error: ${e.message}\n`);
293
+ process.exit(1);
294
+ }
295
+ }
296
+
297
+ main();