pi-web-toolkit 0.2.1 → 0.3.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,147 @@
1
+ /**
2
+ * Firecrawl Interact Extension — natural-language browser interaction (keyless)
3
+ *
4
+ * Provides a `firecrawl_interact` tool that scrapes a URL to start a live
5
+ * Firecrawl browser session, then drives the page with a natural-language
6
+ * prompt (or code) and returns the result. It is an escape hatch for
7
+ * interactive pages the local agent-browser tool cannot run (missing CLI,
8
+ * missing OS browser deps), and underpins the automatic `web_browse` fallback.
9
+ *
10
+ * Requires: `npm install -g firecrawl-cli` (optional; degrades gracefully).
11
+ * Privacy: the URL, page content, and prompt are sent to Firecrawl's cloud.
12
+ */
13
+
14
+ import {
15
+ defineTool,
16
+ type ExtensionAPI,
17
+ formatSize,
18
+ DEFAULT_MAX_BYTES,
19
+ DEFAULT_MAX_LINES,
20
+ } from "@earendil-works/pi-coding-agent";
21
+ import { Text } from "@earendil-works/pi-tui";
22
+ import { Type, type Static } from "typebox";
23
+ import { StringEnum } from "@earendil-works/pi-ai";
24
+ import { interactKeyless } from "./utils/firecrawl";
25
+ import { writeWithFallback } from "./utils/output-sink";
26
+ import { abbreviateUrl, getErrorText, normalizeWhitespace } from "./utils/render-helpers";
27
+
28
+ export const FirecrawlInteractParamsSchema = Type.Object({
29
+ url: Type.String({ description: "Full URL to open and interact with" }),
30
+ prompt: Type.Optional(Type.String({ description: "Natural-language task for the AI agent (e.g. 'Click the pricing tab and return the price')" })),
31
+ code: Type.Optional(Type.String({ description: "Code to execute in the browser sandbox instead of a prompt" })),
32
+ language: Type.Optional(StringEnum(["node", "python", "bash"] as const)),
33
+ timeout: Type.Optional(Type.Integer({ description: "Timeout in seconds (1-300). Default: 30", minimum: 1, maximum: 300 })),
34
+ });
35
+
36
+ export type FirecrawlInteractInput = Static<typeof FirecrawlInteractParamsSchema>;
37
+
38
+ const firecrawlInteractTool = defineTool({
39
+ name: "firecrawl_interact",
40
+ label: "Firecrawl Interact",
41
+ description: [
42
+ "Open a URL in a live Firecrawl browser session and drive it with a natural-language",
43
+ "prompt (or code), returning the result. Keyless — no API key, no signup.",
44
+ "Use firecrawl_interact when the local web_browse cannot run, or when you want",
45
+ "natural-language page interaction without CSS selectors.",
46
+ "Privacy: the URL, page content, and prompt are sent to Firecrawl's cloud.",
47
+ `Output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}; if truncated, full output is saved to a temp file.`,
48
+ ].join(" "),
49
+ promptSnippet: "Drive a page via Firecrawl keyless (natural-language interaction)",
50
+ promptGuidelines: [
51
+ "Prefer web_browse first; reach for firecrawl_interact when web_browse can't run or you want NL interaction.",
52
+ "Write each prompt as a single, focused task; the session can be reused across calls.",
53
+ "Always pass the full URL including https://.",
54
+ ],
55
+ parameters: FirecrawlInteractParamsSchema,
56
+
57
+ async execute(_toolCallId, params, signal) {
58
+ if (!params.prompt && !params.code) {
59
+ throw new Error("firecrawl_interact requires either a prompt or code.");
60
+ }
61
+ const out = await interactKeyless(
62
+ params.url,
63
+ { prompt: params.prompt, code: params.code, language: params.language, timeout: params.timeout },
64
+ signal,
65
+ );
66
+
67
+ if (!out.ok) {
68
+ const reason = out.failure?.reason ?? "unknown error";
69
+ throw new Error(`Firecrawl interact failed (${out.failure?.kind}): ${reason}`);
70
+ }
71
+
72
+ const rawText = `Interacted: ${params.url}\n(via Firecrawl keyless${out.creditsUsed !== undefined ? `, ${out.creditsUsed} credits` : ""})\n${out.liveViewUrl ? `Live view: ${out.liveViewUrl}\n` : ""}\n---\n\n${out.output || "(no output)"}`;
73
+ const sink = await writeWithFallback(rawText, { tmpPrefix: "pi-firecrawl-interact-" });
74
+ const preview = (out.output || "").replace(/\s+/g, " ").trim().slice(0, 500);
75
+
76
+ return {
77
+ content: [{ type: "text", text: sink.text }],
78
+ details: {
79
+ url: params.url,
80
+ output: out.output,
81
+ preview,
82
+ fullOutputPath: sink.fullOutputPath,
83
+ liveViewUrl: out.liveViewUrl,
84
+ creditsUsed: out.creditsUsed,
85
+ viaFirecrawl: true,
86
+ },
87
+ };
88
+ },
89
+
90
+ renderCall(args, theme) {
91
+ let text = theme.fg("toolTitle", theme.bold("firecrawl_interact "));
92
+ text += theme.fg("muted", args.url);
93
+ if (args.prompt) text += theme.fg("dim", ` — ${args.prompt.slice(0, 60)}`);
94
+ return new Text(text, 0, 0);
95
+ },
96
+
97
+ renderResult(result, { expanded, isPartial }, theme, context) {
98
+ const isError = context?.isError ?? false;
99
+
100
+ if (isPartial) {
101
+ return new Text(theme.fg("warning", "Interacting via Firecrawl..."), 0, 0);
102
+ }
103
+
104
+ const details = result.details as {
105
+ url?: string;
106
+ output?: string;
107
+ preview?: string;
108
+ fullOutputPath?: string;
109
+ liveViewUrl?: string;
110
+ creditsUsed?: number;
111
+ } | undefined;
112
+
113
+ if (isError) {
114
+ const errText = getErrorText(result);
115
+ let text = theme.fg("error", "✗ Firecrawl interact failed");
116
+ if (details?.url) text += ` ${theme.fg("dim", abbreviateUrl(details.url))}`;
117
+ text += `\n\n ${theme.fg("toolOutput", errText)}`;
118
+ return new Text(text, 0, 0);
119
+ }
120
+
121
+ let text = theme.fg("success", "✓ Interacted");
122
+ text += theme.fg("accent", " [Firecrawl keyless]");
123
+ if (details?.url) text += ` ${theme.fg("dim", abbreviateUrl(details.url))}`;
124
+ if (details?.creditsUsed !== undefined) text += theme.fg("muted", ` ${details.creditsUsed} credits`);
125
+
126
+ if (!expanded && details?.preview) {
127
+ const snippet = normalizeWhitespace(details.preview);
128
+ const short = snippet.length > 160 ? snippet.slice(0, 160).replace(/\s+\S*$/, "") + "..." : snippet;
129
+ text += `\n\n ${theme.fg("muted", short)}`;
130
+ }
131
+
132
+ if (expanded) {
133
+ if (details?.output) {
134
+ text += `\n\n ${theme.fg("muted", normalizeWhitespace(details.output))}`;
135
+ }
136
+ if (details?.fullOutputPath) {
137
+ text += `\n\n${theme.fg("accent", `Full output: ${details.fullOutputPath}`)}`;
138
+ }
139
+ }
140
+
141
+ return new Text(text, 0, 0);
142
+ },
143
+ });
144
+
145
+ export default function (pi: ExtensionAPI) {
146
+ pi.registerTool(firecrawlInteractTool);
147
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Firecrawl Scrape Extension — single-page fetch via firecrawl-cli (keyless)
3
+ *
4
+ * Provides a `firecrawl_scrape` tool that fetches a single URL as clean
5
+ * markdown through the official Firecrawl CLI in keyless mode (no API key,
6
+ * no signup). It is an explicit escape hatch for hard targets the local
7
+ * scrapling fetcher cannot handle (anti-bot, heavy JS, PDFs), and also
8
+ * underpins the automatic `web_fetch` fallback.
9
+ *
10
+ * Requires: `npm install -g firecrawl-cli` (optional; the tool degrades
11
+ * gracefully and reports when the CLI is unavailable).
12
+ *
13
+ * Privacy: the URL and page content are sent to Firecrawl's cloud.
14
+ */
15
+
16
+ import {
17
+ defineTool,
18
+ type ExtensionAPI,
19
+ formatSize,
20
+ DEFAULT_MAX_BYTES,
21
+ DEFAULT_MAX_LINES,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import { Text } from "@earendil-works/pi-tui";
24
+ import { Type, type Static } from "typebox";
25
+ import { scrapeKeyless } from "./utils/firecrawl";
26
+ import { extractPreview } from "./utils/content-preview";
27
+ import { writeWithFallback } from "./utils/output-sink";
28
+ import { abbreviateUrl, getErrorText, normalizeWhitespace, formatExtraction } from "./utils/render-helpers";
29
+
30
+ export const FirecrawlScrapeParamsSchema = Type.Object({
31
+ url: Type.String({ description: "Full URL to fetch (e.g. https://example.com/article)" }),
32
+ waitFor: Type.Optional(Type.Integer({ description: "Wait (ms) before scraping for JS-rendered content", minimum: 0 })),
33
+ includeTags: Type.Optional(Type.Array(Type.String(), { description: "HTML tags to include (Firecrawl tag filter, not a CSS selector)" })),
34
+ excludeTags: Type.Optional(Type.Array(Type.String(), { description: "HTML tags to exclude" })),
35
+ onlyMainContent: Type.Optional(Type.Boolean({ description: "Extract only main content (drop nav/footer). Default: true", default: true })),
36
+ });
37
+
38
+ export type FirecrawlScrapeInput = Static<typeof FirecrawlScrapeParamsSchema>;
39
+
40
+ const firecrawlScrapeTool = defineTool({
41
+ name: "firecrawl_scrape",
42
+ label: "Firecrawl Scrape",
43
+ description: [
44
+ "Fetch a single page as clean markdown via Firecrawl (keyless — no API key, no signup).",
45
+ "Use firecrawl_scrape when the local web_fetch fails on a hard target (anti-bot,",
46
+ "JavaScript-heavy pages, PDFs) or when you need Firecrawl's cloud rendering directly.",
47
+ "Privacy: the URL and page content are sent to Firecrawl's cloud.",
48
+ `Output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}; if truncated, full output is saved to a temp file.`,
49
+ ].join(" "),
50
+ promptSnippet: "Fetch a single page via Firecrawl keyless (anti-bot / JS / PDF fallback)",
51
+ promptGuidelines: [
52
+ "Prefer web_fetch first; reach for firecrawl_scrape when web_fetch fails or you need cloud rendering.",
53
+ "firecrawl_scrape handles anti-bot protection, JS-heavy SPAs, and PDFs that scrapling may miss.",
54
+ "Always pass the full URL including https://.",
55
+ ],
56
+ parameters: FirecrawlScrapeParamsSchema,
57
+
58
+ async execute(_toolCallId, params, signal) {
59
+ const out = await scrapeKeyless(params.url, {
60
+ waitFor: params.waitFor,
61
+ includeTags: params.includeTags,
62
+ excludeTags: params.excludeTags,
63
+ onlyMainContent: params.onlyMainContent,
64
+ }, signal);
65
+
66
+ if (!out.ok) {
67
+ const reason = out.failure?.reason ?? "unknown error";
68
+ throw new Error(`Firecrawl scrape failed (${out.failure?.kind}): ${reason}`);
69
+ }
70
+
71
+ const preview = extractPreview(out.content, 500);
72
+ const rawText = `Fetched: ${params.url}\n(via Firecrawl keyless${out.creditsUsed !== undefined ? `, ${out.creditsUsed} credits` : ""})\nSize: ${out.bytes} bytes\n\n---\n\n${out.content}`;
73
+ const sink = await writeWithFallback(rawText, { tmpPrefix: "pi-firecrawl-scrape-full-" });
74
+
75
+ return {
76
+ content: [{ type: "text", text: sink.text }],
77
+ details: {
78
+ url: params.url,
79
+ bytes: out.bytes,
80
+ fullOutputPath: sink.fullOutputPath,
81
+ preview,
82
+ title: out.title,
83
+ creditsUsed: out.creditsUsed,
84
+ viaFirecrawl: true,
85
+ },
86
+ };
87
+ },
88
+
89
+ renderCall(args, theme) {
90
+ let text = theme.fg("toolTitle", theme.bold("firecrawl_scrape "));
91
+ text += theme.fg("muted", args.url);
92
+ if (args.waitFor) {
93
+ text += theme.fg("dim", ` [wait=${args.waitFor}]`);
94
+ }
95
+ return new Text(text, 0, 0);
96
+ },
97
+
98
+ renderResult(result, { expanded, isPartial }, theme, context) {
99
+ const isError = context?.isError ?? false;
100
+
101
+ if (isPartial) {
102
+ return new Text(theme.fg("warning", "Scraping via Firecrawl..."), 0, 0);
103
+ }
104
+
105
+ const details = result.details as {
106
+ url?: string;
107
+ bytes?: number;
108
+ fullOutputPath?: string;
109
+ preview?: string;
110
+ title?: string;
111
+ creditsUsed?: number;
112
+ } | undefined;
113
+
114
+ if (isError) {
115
+ const errText = getErrorText(result);
116
+ let text = theme.fg("error", "✗ Firecrawl scrape failed");
117
+ if (details?.url) text += ` ${theme.fg("dim", abbreviateUrl(details.url))}`;
118
+ text += `\n\n ${theme.fg("toolOutput", errText)}`;
119
+ return new Text(text, 0, 0);
120
+ }
121
+
122
+ let text = theme.fg("success", "✓ Fetched");
123
+ text += theme.fg("accent", " [Firecrawl keyless]");
124
+ if (details?.title) {
125
+ text += ` ${theme.fg("toolTitle", details.title)}`;
126
+ } else if (details?.url) {
127
+ text += ` ${theme.fg("dim", abbreviateUrl(details.url))}`;
128
+ }
129
+ if (details?.bytes && details?.preview) {
130
+ text += ` ${theme.fg("muted", formatExtraction(details.bytes, details.preview.length))}`;
131
+ }
132
+
133
+ if (!expanded && details?.preview) {
134
+ const snippet = normalizeWhitespace(details.preview);
135
+ const short = snippet.length > 160 ? snippet.slice(0, 160).replace(/\s+\S*$/, "") + "..." : snippet;
136
+ text += `\n\n ${theme.fg("muted", short)}`;
137
+ }
138
+
139
+ if (expanded) {
140
+ if (details?.preview) {
141
+ text += `\n\n ${theme.fg("muted", normalizeWhitespace(details.preview))}`;
142
+ }
143
+ if (details?.fullOutputPath) {
144
+ text += `\n\n${theme.fg("accent", `Full output: ${details.fullOutputPath}`)}`;
145
+ }
146
+ }
147
+
148
+ return new Text(text, 0, 0);
149
+ },
150
+ });
151
+
152
+ export default function (pi: ExtensionAPI) {
153
+ pi.registerTool(firecrawlScrapeTool);
154
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Firecrawl Search Extension — web search via firecrawl-cli (keyless)
3
+ *
4
+ * Provides a `firecrawl_search` tool that searches the web through the
5
+ * official Firecrawl CLI in keyless mode (no API key, no signup). It exposes
6
+ * Firecrawl-specific capabilities the local SearXNG tool does not: sources
7
+ * (web/images/news), categories (github/research/pdf), and domain filters.
8
+ *
9
+ * Requires: `npm install -g firecrawl-cli` (optional; degrades gracefully).
10
+ * Privacy: the query is sent to Firecrawl's cloud.
11
+ */
12
+
13
+ import {
14
+ defineTool,
15
+ type ExtensionAPI,
16
+ formatSize,
17
+ DEFAULT_MAX_BYTES,
18
+ DEFAULT_MAX_LINES,
19
+ } from "@earendil-works/pi-coding-agent";
20
+ import { Text } from "@earendil-works/pi-tui";
21
+ import { Type, type Static } from "typebox";
22
+ import { StringEnum } from "@earendil-works/pi-ai";
23
+ import { searchKeyless, buildSearchQuery } from "./utils/firecrawl";
24
+ import { writeWithFallback } from "./utils/output-sink";
25
+ import { abbreviateUrl, getDomain, getErrorText, normalizeWhitespace } from "./utils/render-helpers";
26
+
27
+ export const FirecrawlSearchParamsSchema = Type.Object({
28
+ query: Type.String({ description: "Search query" }),
29
+ limit: Type.Optional(Type.Integer({ description: "Max results (1-100). Default: 10", minimum: 1, maximum: 100 })),
30
+ sources: Type.Optional(Type.Array(StringEnum(["web", "images", "news"] as const), { description: "Sources to search. Default: web" })),
31
+ categories: Type.Optional(Type.Array(StringEnum(["github", "research", "pdf"] as const), { description: "Filter by GitHub / research papers / PDFs" })),
32
+ country: Type.Optional(Type.String({ description: "ISO country code for geo-targeting (e.g. US, DE)" })),
33
+ tbs: Type.Optional(Type.String({ description: "Time filter: qdr:h (hour), qdr:d (day), qdr:w (week), qdr:m (month), qdr:y (year)" })),
34
+ location: Type.Optional(Type.String({ description: "Geo-targeting location (e.g. 'Berlin,Germany')" })),
35
+ includeDomains: Type.Optional(Type.Array(Type.String(), { description: "Restrict results to these domains (hostnames)" })),
36
+ excludeDomains: Type.Optional(Type.Array(Type.String(), { description: "Exclude results from these domains (hostnames)" })),
37
+ });
38
+
39
+ export type FirecrawlSearchInput = Static<typeof FirecrawlSearchParamsSchema>;
40
+
41
+ const firecrawlSearchTool = defineTool({
42
+ name: "firecrawl_search",
43
+ label: "Firecrawl Search",
44
+ description: [
45
+ "Search the web via Firecrawl (keyless — no API key, no signup).",
46
+ "Supports sources (web/images/news) and categories (github/research/pdf) that",
47
+ "SearXNG does not. Use as an escape hatch or when web_search returns nothing.",
48
+ "Privacy: the query is sent to Firecrawl's cloud.",
49
+ `Output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}; if truncated, full output is saved to a temp file.`,
50
+ ].join(" "),
51
+ promptSnippet: "Search the web via Firecrawl keyless (categories, sources, domain filters)",
52
+ promptGuidelines: [
53
+ "Prefer web_search first; reach for firecrawl_search when web_search fails or returns nothing.",
54
+ "Use categories=[\"github\"], [\"research\"], or [\"pdf\"] for source-type-specific discovery.",
55
+ "Use includeDomains/excludeDomains to scope results to specific sites.",
56
+ ],
57
+ parameters: FirecrawlSearchParamsSchema,
58
+
59
+ async execute(_toolCallId, params, signal) {
60
+ const query = buildSearchQuery(params.query, params.includeDomains, params.excludeDomains);
61
+ const out = await searchKeyless(query, {
62
+ limit: params.limit,
63
+ sources: params.sources,
64
+ categories: params.categories,
65
+ country: params.country,
66
+ tbs: params.tbs,
67
+ location: params.location,
68
+ }, signal);
69
+
70
+ if (!out.ok) {
71
+ const reason = out.failure?.reason ?? "unknown error";
72
+ throw new Error(`Firecrawl search failed (${out.failure?.kind}): ${reason}`);
73
+ }
74
+
75
+ const lines: string[] = [`Results for "${params.query}" (via Firecrawl keyless${out.creditsUsed !== undefined ? `, ${out.creditsUsed} credits` : ""}):`, ""];
76
+ for (let i = 0; i < out.results.length; i++) {
77
+ const r = out.results[i];
78
+ lines.push(`${i + 1}. ${r.title ?? "(untitled)"}`);
79
+ lines.push(` URL: ${r.url}`);
80
+ if (r.description) lines.push(` ${r.description.replace(/\s+/g, " ").trim()}`);
81
+ if (r.category) lines.push(` [category: ${r.category}]`);
82
+ lines.push("");
83
+ }
84
+
85
+ const rawText = lines.join("\n");
86
+ const sink = await writeWithFallback(rawText, { tmpPrefix: "pi-firecrawl-search-", alwaysWriteFile: true });
87
+
88
+ return {
89
+ content: [{ type: "text", text: sink.text }],
90
+ details: {
91
+ query: params.query,
92
+ totalResults: out.results.length,
93
+ results: out.results,
94
+ creditsUsed: out.creditsUsed,
95
+ fullOutputPath: sink.fullOutputPath,
96
+ viaFirecrawl: true,
97
+ },
98
+ };
99
+ },
100
+
101
+ renderCall(args, theme) {
102
+ let text = theme.fg("toolTitle", theme.bold("firecrawl_search "));
103
+ text += theme.fg("muted", args.query);
104
+ if (args.categories) text += theme.fg("dim", ` [${args.categories.join(",")}]`);
105
+ return new Text(text, 0, 0);
106
+ },
107
+
108
+ renderResult(result, { expanded, isPartial }, theme, context) {
109
+ const isError = context?.isError ?? false;
110
+
111
+ if (isPartial) {
112
+ const query = (result.details as any)?.query as string | undefined;
113
+ const label = query ? `Searching "${query}" via Firecrawl...` : "Searching via Firecrawl...";
114
+ return new Text(theme.fg("warning", label), 0, 0);
115
+ }
116
+
117
+ const details = result.details as {
118
+ query?: string;
119
+ totalResults?: number;
120
+ results?: Array<{ title?: string; url?: string; description?: string; category?: string }>;
121
+ creditsUsed?: number;
122
+ fullOutputPath?: string;
123
+ } | undefined;
124
+
125
+ if (isError) {
126
+ const errText = getErrorText(result);
127
+ let text = theme.fg("error", "✗ Firecrawl search failed");
128
+ if (details?.query) text += ` ${theme.fg("dim", details.query)}`;
129
+ text += `\n\n ${theme.fg("toolOutput", errText)}`;
130
+ return new Text(text, 0, 0);
131
+ }
132
+
133
+ const showing = details?.results?.length ?? 0;
134
+ let text = theme.fg("success", `✓ ${showing} results`);
135
+ text += theme.fg("accent", " [Firecrawl keyless]");
136
+ if (details?.creditsUsed !== undefined) {
137
+ text += theme.fg("muted", ` ${details.creditsUsed} credits`);
138
+ }
139
+
140
+ const top = (details?.results ?? []).slice(0, expanded ? 10 : 3);
141
+ for (let i = 0; i < top.length; i++) {
142
+ const r = top[i];
143
+ const domain = r.url ? theme.fg("dim", ` ${getDomain(r.url)}`) : "";
144
+ text += `\n [${i + 1}] ${theme.fg("toolTitle", r.title ?? "(untitled)")}${domain}`;
145
+ if (r.description) {
146
+ const snippet = normalizeWhitespace(r.description);
147
+ const short = snippet.length > 90 ? snippet.slice(0, 90).replace(/\s+\S*$/, "") + "..." : snippet;
148
+ text += `\n ${theme.fg("muted", short)}`;
149
+ }
150
+ }
151
+ if (showing > top.length) {
152
+ text += `\n ${theme.fg("muted", `... and ${showing - top.length} more${expanded ? "" : " (Ctrl+O for full list)"}`)}`;
153
+ }
154
+
155
+ if (expanded && details?.fullOutputPath) {
156
+ text += `\n${theme.fg("accent", `Full output: ${details.fullOutputPath}`)}`;
157
+ }
158
+
159
+ return new Text(text, 0, 0);
160
+ },
161
+ });
162
+
163
+ export default function (pi: ExtensionAPI) {
164
+ pi.registerTool(firecrawlSearchTool);
165
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Registers all web research tools as a single extension:
5
5
  * - web_search: Search via SearXNG
6
- * - web_fetch: Fetch static pages with scrapling
6
+ * - web_fetch: Fetch a single page with scrapling
7
7
  * - web_browse: Interactive browser automation via agent-browser
8
8
  * - web_batch_fetch: Concurrent multi-page fetching
9
9
  */
@@ -13,10 +13,16 @@ import registerWebSearch from "./web_search";
13
13
  import registerWebFetch from "./web_fetch";
14
14
  import registerWebBrowse from "./web_browse";
15
15
  import registerWebBatchFetch from "./web_batch_fetch";
16
+ import registerFirecrawlScrape from "./firecrawl_scrape";
17
+ import registerFirecrawlSearch from "./firecrawl_search";
18
+ import registerFirecrawlInteract from "./firecrawl_interact";
16
19
 
17
20
  export default function (pi: ExtensionAPI) {
18
21
  registerWebSearch(pi);
19
22
  registerWebFetch(pi);
20
23
  registerWebBrowse(pi);
21
24
  registerWebBatchFetch(pi);
25
+ registerFirecrawlScrape(pi);
26
+ registerFirecrawlSearch(pi);
27
+ registerFirecrawlInteract(pi);
22
28
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Provides a single interface for running external CLI commands
5
5
  * with consistent signal handling, timeout support, and stdout/stderr
6
- * collection. Enables testability by allowing the runner to be swapped.
6
+ * collection.
7
7
  */
8
8
 
9
9
  import { spawn, type ChildProcess } from "node:child_process";
@@ -17,6 +17,8 @@ export interface CLIRunOptions {
17
17
  timeout?: number;
18
18
  /** AbortSignal for cancellation. */
19
19
  signal?: AbortSignal;
20
+ /** Optional environment override for the child process. */
21
+ env?: NodeJS.ProcessEnv;
20
22
  }
21
23
 
22
24
  export interface CLIRunResult {
@@ -44,6 +46,7 @@ export function runCLI(options: CLIRunOptions): Promise<CLIRunResult> {
44
46
  const proc = spawn(options.command, options.args, {
45
47
  shell: false,
46
48
  stdio: stdio as any,
49
+ env: options.env,
47
50
  }) as ChildProcess;
48
51
 
49
52
  let stdout = "";