@narumitw/pi-firecrawl 0.1.33 → 0.1.34

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.
Files changed (3) hide show
  1. package/README.md +41 -2
  2. package/package.json +1 -1
  3. package/src/firecrawl.ts +523 -16
package/README.md CHANGED
@@ -15,7 +15,10 @@ Use it to give your AI coding agent reliable web research capabilities for docum
15
15
  - Search the web and optionally scrape search result pages.
16
16
  - Supports Firecrawl API endpoint overrides.
17
17
  - Shows statusline activity only while Firecrawl tools are running.
18
- - Never logs or displays your Firecrawl API key.
18
+ - Provides a `/firecrawl` menu with configuration help and tool controls.
19
+ - Provides a Plan-mode-style selector for choosing individual Firecrawl tools.
20
+ - Persists the selected Firecrawl tools across Pi restarts.
21
+ - Never logs, displays, or stores your Firecrawl API key.
19
22
 
20
23
  ## 📦 Install
21
24
 
@@ -67,7 +70,43 @@ All tools fail with a clear configuration error when `FIRECRAWL_API_KEY` is miss
67
70
  /firecrawl
68
71
  ```
69
72
 
70
- Shows whether the extension sees an API key and which Firecrawl API URL it will call.
73
+ Opens a menu with configuration quick start, command usage, tool status, controls for enabling
74
+ or disabling all Firecrawl tools, and a selector for choosing individual tools.
75
+
76
+ Direct subcommands are also available:
77
+
78
+ ```text
79
+ /firecrawl help
80
+ /firecrawl config
81
+ /firecrawl quickstart
82
+ /firecrawl status
83
+ /firecrawl tools
84
+ /firecrawl toggle
85
+ /firecrawl enable
86
+ /firecrawl disable
87
+ ```
88
+
89
+ - `help` shows command usage.
90
+ - `config` shows API-key presence and API URL without displaying the API key value.
91
+ - `quickstart` is an alias for `config`.
92
+ - `status` shows runtime tool state, persisted selection, settings file path, API-key presence,
93
+ API URL, and active non-Firecrawl tool count.
94
+ - `tools` opens a Plan-mode-style selector for choosing individual `firecrawl_*` tools.
95
+ - `toggle` is an alias for `tools`.
96
+ - `enable` enables all `firecrawl_*` tools for future turns.
97
+ - `disable` disables all `firecrawl_*` tools for future turns. The slash command remains
98
+ available.
99
+
100
+ The selected tool names are saved to:
101
+
102
+ ```text
103
+ ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-firecrawl-settings.json
104
+ ```
105
+
106
+ When the file is missing or invalid, the extension preserves Pi's current active-tool policy
107
+ instead of enabling tools by itself. A valid saved selection is restored on Pi startup and
108
+ `/reload`. The settings file stores only tool names and a timestamp; it never stores
109
+ `FIRECRAWL_API_KEY`, request headers, or other secrets.
71
110
 
72
111
  ## 🚀 Examples
73
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-firecrawl",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "description": "Pi extension that exposes Firecrawl web scraping and crawling tools.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/firecrawl.ts CHANGED
@@ -1,15 +1,82 @@
1
- import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import {
5
+ defineTool,
6
+ type ExtensionAPI,
7
+ type ExtensionCommandContext,
8
+ } from "@mariozechner/pi-coding-agent";
2
9
  import { Type } from "typebox";
3
10
 
4
11
  const DEFAULT_API_URL = "https://api.firecrawl.dev/v1";
5
12
  const STATUS_KEY = "firecrawl";
13
+ const SETTINGS_FILE = join(
14
+ process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
15
+ "pi-firecrawl-settings.json",
16
+ );
17
+ const FIRECRAWL_TOOL_NAMES = [
18
+ "firecrawl_scrape",
19
+ "firecrawl_crawl",
20
+ "firecrawl_crawl_status",
21
+ "firecrawl_map",
22
+ "firecrawl_search",
23
+ ] as const;
24
+ const COMMAND_COMPLETIONS = [
25
+ { value: "help", label: "Show command usage" },
26
+ { value: "config", label: "Show configuration quick start" },
27
+ { value: "quickstart", label: "Show configuration quick start" },
28
+ { value: "status", label: "Show tool and settings status" },
29
+ { value: "tools", label: "Select Firecrawl tools" },
30
+ { value: "toggle", label: "Select Firecrawl tools" },
31
+ { value: "enable", label: "Enable all Firecrawl tools" },
32
+ { value: "disable", label: "Disable all Firecrawl tools" },
33
+ ];
34
+ const MENU_OPTIONS = {
35
+ config: "Configuration quick start",
36
+ help: "Command usage guide",
37
+ status: "Show tool status",
38
+ tools: "Select Firecrawl tools",
39
+ enable: "Enable all Firecrawl tools",
40
+ disable: "Disable all Firecrawl tools",
41
+ } as const;
42
+ const TOOL_SELECTOR_DONE = "Done";
43
+ const TOOL_SELECTOR_ENABLE_ALL = "Enable all Firecrawl tools";
44
+ const TOOL_SELECTOR_DISABLE_ALL = "Disable all Firecrawl tools";
6
45
 
7
46
  const StringArray = Type.Array(Type.String());
8
47
 
48
+ type FirecrawlToolName = (typeof FIRECRAWL_TOOL_NAMES)[number];
49
+ type ToolRuntimeStatus = "enabled" | "disabled" | "partial";
50
+ type CommandAction =
51
+ | "menu"
52
+ | "help"
53
+ | "config"
54
+ | "quickstart"
55
+ | "status"
56
+ | "tools"
57
+ | "enable"
58
+ | "disable";
59
+ type CommandContext = ExtensionCommandContext;
60
+ type ToolSelectorAction = "enableAll" | "disableAll" | "done";
61
+ type ToolSelectorRow =
62
+ | { kind: "tool"; toolName: FirecrawlToolName }
63
+ | { kind: "action"; action: ToolSelectorAction; label: string };
64
+
9
65
  interface FirecrawlState {
10
66
  apiUrl: string;
11
67
  }
12
68
 
69
+ interface FirecrawlSettings {
70
+ tools: FirecrawlToolName[];
71
+ updatedAt: number;
72
+ }
73
+
74
+ interface ToolStatusSummary {
75
+ runtimeStatus: ToolRuntimeStatus;
76
+ activeFirecrawlToolCount: number;
77
+ activeNonFirecrawlToolCount: number;
78
+ }
79
+
13
80
  interface StatusContext {
14
81
  ui: { setStatus: (key: string, value: string | undefined) => void };
15
82
  }
@@ -19,7 +86,7 @@ const state: FirecrawlState = {
19
86
  };
20
87
 
21
88
  const scrapeTool = defineTool({
22
- name: "firecrawl_scrape",
89
+ name: FIRECRAWL_TOOL_NAMES[0],
23
90
  label: "Firecrawl: Scrape",
24
91
  description: "Scrape a single URL through Firecrawl and return requested formats.",
25
92
  promptSnippet: "Scrape a URL through Firecrawl",
@@ -81,7 +148,7 @@ const scrapeTool = defineTool({
81
148
  });
82
149
 
83
150
  const crawlTool = defineTool({
84
- name: "firecrawl_crawl",
151
+ name: FIRECRAWL_TOOL_NAMES[1],
85
152
  label: "Firecrawl: Crawl",
86
153
  description: "Start a Firecrawl crawl job for a website.",
87
154
  promptSnippet: "Start a Firecrawl site crawl job",
@@ -119,7 +186,7 @@ const crawlTool = defineTool({
119
186
  });
120
187
 
121
188
  const crawlStatusTool = defineTool({
122
- name: "firecrawl_crawl_status",
189
+ name: FIRECRAWL_TOOL_NAMES[2],
123
190
  label: "Firecrawl: Crawl Status",
124
191
  description: "Check a Firecrawl crawl job status and retrieve completed crawl data.",
125
192
  promptSnippet: "Check a Firecrawl crawl job status",
@@ -140,7 +207,7 @@ const crawlStatusTool = defineTool({
140
207
  });
141
208
 
142
209
  const mapTool = defineTool({
143
- name: "firecrawl_map",
210
+ name: FIRECRAWL_TOOL_NAMES[3],
144
211
  label: "Firecrawl: Map",
145
212
  description: "Discover URLs for a site through Firecrawl's map endpoint.",
146
213
  promptSnippet: "Map/discover URLs for a site through Firecrawl",
@@ -167,7 +234,7 @@ const mapTool = defineTool({
167
234
  });
168
235
 
169
236
  const searchTool = defineTool({
170
- name: "firecrawl_search",
237
+ name: FIRECRAWL_TOOL_NAMES[4],
171
238
  label: "Firecrawl: Search",
172
239
  description: "Search the web through Firecrawl and optionally scrape search results.",
173
240
  promptSnippet: "Search the web through Firecrawl",
@@ -198,14 +265,23 @@ export default function firecrawl(pi: ExtensionAPI) {
198
265
  pi.registerTool(searchTool);
199
266
 
200
267
  pi.registerCommand("firecrawl", {
201
- description: "Show Firecrawl extension configuration status",
202
- handler: async (_args, ctx) => {
203
- ctx.ui.notify(buildStatusMessage(), hasApiKey() ? "info" : "warning");
268
+ description: "Open Firecrawl help and tool controls",
269
+ getArgumentCompletions: (prefix) => commandCompletions(prefix),
270
+ handler: async (args, ctx) => {
271
+ await handleFirecrawlCommand(pi, args, ctx);
204
272
  },
205
273
  });
206
274
 
207
- pi.on("session_start", (_event, ctx) => {
275
+ pi.on("session_start", async (_event, ctx) => {
208
276
  ctx.ui.setStatus(STATUS_KEY, undefined);
277
+ const settings = await loadSettings();
278
+ if (settings.kind === "loaded") {
279
+ applyFirecrawlTools(pi, settings.settings.tools);
280
+ return;
281
+ }
282
+ if (settings.kind === "invalid") {
283
+ ctx.ui.notify(`Firecrawl settings ignored: ${settings.reason}`, "warning");
284
+ }
209
285
  });
210
286
 
211
287
  pi.on("session_shutdown", (_event, ctx) => {
@@ -213,6 +289,443 @@ export default function firecrawl(pi: ExtensionAPI) {
213
289
  });
214
290
  }
215
291
 
292
+ async function handleFirecrawlCommand(pi: ExtensionAPI, args: string, ctx: CommandContext) {
293
+ const command = parseCommand(args);
294
+ switch (command) {
295
+ case "menu":
296
+ await showMenu(pi, ctx);
297
+ return;
298
+ case "help":
299
+ ctx.ui.notify(buildCommandGuide(), "info");
300
+ return;
301
+ case "config":
302
+ case "quickstart":
303
+ ctx.ui.notify(buildConfigMessage(), hasApiKey() ? "info" : "warning");
304
+ return;
305
+ case "status":
306
+ ctx.ui.notify(await buildStatusMessage(pi), hasApiKey() ? "info" : "warning");
307
+ return;
308
+ case "tools":
309
+ await showToolSelector(pi, ctx);
310
+ return;
311
+ case "enable":
312
+ await updateFirecrawlTools(pi, ctx, allFirecrawlTools(), "enabled all");
313
+ return;
314
+ case "disable":
315
+ await updateFirecrawlTools(pi, ctx, [], "disabled all");
316
+ return;
317
+ }
318
+
319
+ ctx.ui.notify(`Unknown /firecrawl command: ${args.trim()}\n\n${buildCommandGuide()}`, "warning");
320
+ }
321
+
322
+ async function showMenu(pi: ExtensionAPI, ctx: CommandContext) {
323
+ if (!ctx.hasUI) {
324
+ ctx.ui.notify(`${buildCommandGuide()}\n\n${await buildStatusMessage(pi)}`, hasApiKey() ? "info" : "warning");
325
+ return;
326
+ }
327
+
328
+ const choice = await ctx.ui.select("Firecrawl", Object.values(MENU_OPTIONS));
329
+ switch (choice) {
330
+ case MENU_OPTIONS.config:
331
+ ctx.ui.notify(buildConfigMessage(), hasApiKey() ? "info" : "warning");
332
+ return;
333
+ case MENU_OPTIONS.help:
334
+ ctx.ui.notify(buildCommandGuide(), "info");
335
+ return;
336
+ case MENU_OPTIONS.status:
337
+ ctx.ui.notify(await buildStatusMessage(pi), hasApiKey() ? "info" : "warning");
338
+ return;
339
+ case MENU_OPTIONS.tools:
340
+ await showToolSelector(pi, ctx);
341
+ return;
342
+ case MENU_OPTIONS.enable:
343
+ await updateFirecrawlTools(pi, ctx, allFirecrawlTools(), "enabled all");
344
+ return;
345
+ case MENU_OPTIONS.disable:
346
+ await updateFirecrawlTools(pi, ctx, [], "disabled all");
347
+ return;
348
+ }
349
+ }
350
+
351
+ function parseCommand(args: string): CommandAction | "unknown" {
352
+ const command = args.trim().toLowerCase();
353
+ if (!command) return "menu";
354
+ if (command === "help") return "help";
355
+ if (command === "config") return "config";
356
+ if (command === "quickstart") return "quickstart";
357
+ if (command === "status") return "status";
358
+ if (command === "tools" || command === "select" || command === "toggle") return "tools";
359
+ if (command === "enable" || command === "on") return "enable";
360
+ if (command === "disable" || command === "off") return "disable";
361
+ return "unknown";
362
+ }
363
+
364
+ function commandCompletions(prefix: string) {
365
+ const normalized = prefix.trim().toLowerCase();
366
+ if (normalized.includes(" ")) return null;
367
+
368
+ const matches = COMMAND_COMPLETIONS.filter((completion) =>
369
+ completion.value.startsWith(normalized),
370
+ );
371
+ return matches.length > 0 ? matches : null;
372
+ }
373
+
374
+ async function showToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
375
+ if (!ctx.hasUI) {
376
+ ctx.ui.notify(
377
+ `Firecrawl tool selection needs an interactive UI.\n\n${await buildStatusMessage(pi)}`,
378
+ hasApiKey() ? "info" : "warning",
379
+ );
380
+ return;
381
+ }
382
+
383
+ let selectedTools = new Set<FirecrawlToolName>(getActiveFirecrawlTools(pi));
384
+ let persistQueue = Promise.resolve();
385
+ const commitSelectedTools = () => {
386
+ const nextSelectedTools = orderedFirecrawlTools(selectedTools);
387
+ applyFirecrawlTools(pi, nextSelectedTools);
388
+ persistQueue = persistQueue.then(() => persistSettings(ctx, nextSelectedTools));
389
+ };
390
+
391
+ const customResult = await ctx.ui.custom<"closed" | undefined>(
392
+ (tui, theme, keybindings, done) => {
393
+ const rows = firecrawlToolSelectorRows();
394
+ let selectedIndex = 0;
395
+ const moveSelection = (delta: number) => {
396
+ selectedIndex = (selectedIndex + delta + rows.length) % rows.length;
397
+ };
398
+ const activateSelectedRow = () => {
399
+ const row = rows[selectedIndex];
400
+ if (!row) return;
401
+
402
+ if (row.kind === "tool") {
403
+ if (selectedTools.has(row.toolName)) selectedTools.delete(row.toolName);
404
+ else selectedTools.add(row.toolName);
405
+ commitSelectedTools();
406
+ return;
407
+ }
408
+
409
+ if (row.action === "enableAll") {
410
+ selectedTools = new Set(allFirecrawlTools());
411
+ commitSelectedTools();
412
+ return;
413
+ }
414
+ if (row.action === "disableAll") {
415
+ selectedTools = new Set();
416
+ commitSelectedTools();
417
+ return;
418
+ }
419
+
420
+ done("closed");
421
+ };
422
+
423
+ return {
424
+ invalidate() {},
425
+ render() {
426
+ return [
427
+ theme.fg("accent", theme.bold(toolSelectorTitle(selectedTools))),
428
+ "",
429
+ ...rows.map((row, index) => {
430
+ const label = formatToolSelectorRow(row, selectedTools);
431
+ if (index === selectedIndex) {
432
+ return `${theme.fg("accent", "›")} ${theme.fg("accent", label)}`;
433
+ }
434
+ return ` ${label}`;
435
+ }),
436
+ "",
437
+ theme.fg("dim", "↑↓ navigate • Enter/Space toggle • Esc close"),
438
+ ];
439
+ },
440
+ handleInput(data: string) {
441
+ if (keybindings.matches(data, "tui.select.up")) {
442
+ moveSelection(-1);
443
+ tui.requestRender();
444
+ return;
445
+ }
446
+ if (keybindings.matches(data, "tui.select.down")) {
447
+ moveSelection(1);
448
+ tui.requestRender();
449
+ return;
450
+ }
451
+ if (keybindings.matches(data, "tui.select.pageUp")) {
452
+ selectedIndex = 0;
453
+ tui.requestRender();
454
+ return;
455
+ }
456
+ if (keybindings.matches(data, "tui.select.pageDown")) {
457
+ selectedIndex = rows.length - 1;
458
+ tui.requestRender();
459
+ return;
460
+ }
461
+ if (keybindings.matches(data, "tui.select.confirm") || data === " ") {
462
+ activateSelectedRow();
463
+ tui.requestRender();
464
+ return;
465
+ }
466
+ if (keybindings.matches(data, "tui.select.cancel")) {
467
+ done("closed");
468
+ }
469
+ },
470
+ };
471
+ },
472
+ );
473
+
474
+ if (customResult !== "closed") {
475
+ await showDialogToolSelector(pi, ctx);
476
+ return;
477
+ }
478
+
479
+ await persistQueue;
480
+ ctx.ui.notify(await buildStatusMessage(pi), hasApiKey() ? "info" : "warning");
481
+ }
482
+
483
+ async function showDialogToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
484
+ let selectedTools = new Set<FirecrawlToolName>(getActiveFirecrawlTools(pi));
485
+ while (true) {
486
+ const rows = firecrawlToolSelectorRows();
487
+ const choices = rows.map((row) => formatToolSelectorRow(row, selectedTools));
488
+ const choice = await ctx.ui.select(toolSelectorTitle(selectedTools), choices);
489
+ if (!choice) break;
490
+
491
+ const row = rows[choices.indexOf(choice)];
492
+ if (!row) continue;
493
+ if (row.kind === "action" && row.action === "done") break;
494
+
495
+ if (row.kind === "tool") {
496
+ if (selectedTools.has(row.toolName)) selectedTools.delete(row.toolName);
497
+ else selectedTools.add(row.toolName);
498
+ } else if (row.action === "enableAll") {
499
+ selectedTools = new Set(allFirecrawlTools());
500
+ } else if (row.action === "disableAll") {
501
+ selectedTools = new Set();
502
+ }
503
+
504
+ await setSelectedFirecrawlTools(pi, ctx, orderedFirecrawlTools(selectedTools));
505
+ }
506
+
507
+ ctx.ui.notify(await buildStatusMessage(pi), hasApiKey() ? "info" : "warning");
508
+ }
509
+
510
+ async function updateFirecrawlTools(
511
+ pi: ExtensionAPI,
512
+ ctx: CommandContext,
513
+ selectedTools: readonly FirecrawlToolName[],
514
+ action: string,
515
+ ) {
516
+ await setSelectedFirecrawlTools(pi, ctx, selectedTools);
517
+ ctx.ui.notify(
518
+ `Firecrawl tools ${action}.\n\n${await buildStatusMessage(pi)}`,
519
+ hasApiKey() ? "info" : "warning",
520
+ );
521
+ }
522
+
523
+ async function setSelectedFirecrawlTools(
524
+ pi: ExtensionAPI,
525
+ ctx: CommandContext,
526
+ selectedTools: readonly FirecrawlToolName[],
527
+ ) {
528
+ applyFirecrawlTools(pi, selectedTools);
529
+ await persistSettings(ctx, selectedTools);
530
+ }
531
+
532
+ function applyFirecrawlTools(pi: ExtensionAPI, selectedTools: readonly FirecrawlToolName[]) {
533
+ const activeToolNames = pi.getActiveTools();
534
+ const firecrawlToolNames = new Set<string>(FIRECRAWL_TOOL_NAMES);
535
+ const activeNonFirecrawlToolNames = activeToolNames.filter(
536
+ (name) => !firecrawlToolNames.has(name),
537
+ );
538
+ pi.setActiveTools(unique([...activeNonFirecrawlToolNames, ...selectedTools]));
539
+ }
540
+
541
+ function getToolStatusSummary(pi: ExtensionAPI): ToolStatusSummary {
542
+ const firecrawlToolNames = new Set<string>(FIRECRAWL_TOOL_NAMES);
543
+ const activeToolNames = new Set(pi.getActiveTools());
544
+ const activeFirecrawlToolCount = FIRECRAWL_TOOL_NAMES.filter((name) =>
545
+ activeToolNames.has(name),
546
+ ).length;
547
+ const activeNonFirecrawlToolCount = Array.from(activeToolNames).filter(
548
+ (name) => !firecrawlToolNames.has(name),
549
+ ).length;
550
+ const runtimeStatus =
551
+ activeFirecrawlToolCount === FIRECRAWL_TOOL_NAMES.length
552
+ ? "enabled"
553
+ : activeFirecrawlToolCount === 0
554
+ ? "disabled"
555
+ : "partial";
556
+
557
+ return { runtimeStatus, activeFirecrawlToolCount, activeNonFirecrawlToolCount };
558
+ }
559
+
560
+ async function buildStatusMessage(pi: ExtensionAPI) {
561
+ const summary = getToolStatusSummary(pi);
562
+ const persistedSetting = await persistedSettingLabel();
563
+ return [
564
+ `Firecrawl tools: ${formatRuntimeStatus(summary)}`,
565
+ `Persisted selection: ${persistedSetting}`,
566
+ `Settings file: ${SETTINGS_FILE}`,
567
+ `Other active tools preserved: ${summary.activeNonFirecrawlToolCount}`,
568
+ `API key: ${hasApiKey() ? "present" : "missing"} (FIRECRAWL_API_KEY)`,
569
+ `API URL: ${state.apiUrl}`,
570
+ ].join("\n");
571
+ }
572
+
573
+ function buildConfigMessage() {
574
+ return [
575
+ "Firecrawl configuration:",
576
+ `API key: ${hasApiKey() ? "present" : "missing"} (FIRECRAWL_API_KEY)`,
577
+ `API URL: ${state.apiUrl}`,
578
+ "Override API URL with FIRECRAWL_API_URL or FIRECRAWL_BASE_URL.",
579
+ "This extension never logs, displays, or stores your Firecrawl API key.",
580
+ ].join("\n");
581
+ }
582
+
583
+ function buildCommandGuide() {
584
+ return [
585
+ "Firecrawl commands:",
586
+ "/firecrawl — open this menu",
587
+ "/firecrawl help — show command usage",
588
+ "/firecrawl config — show API key presence and API URL",
589
+ "/firecrawl quickstart — alias for /firecrawl config",
590
+ "/firecrawl status — show tool and settings status",
591
+ "/firecrawl tools — select individual Firecrawl tools",
592
+ "/firecrawl toggle — alias for /firecrawl tools",
593
+ "/firecrawl enable — enable all Firecrawl tools",
594
+ "/firecrawl disable — disable all Firecrawl tools",
595
+ ].join("\n");
596
+ }
597
+
598
+ function toolSelectorTitle(selectedTools: ReadonlySet<FirecrawlToolName>) {
599
+ return `Firecrawl tools (${selectedTools.size}/${FIRECRAWL_TOOL_NAMES.length}). Non-built-in tools run at user risk.`;
600
+ }
601
+
602
+ function firecrawlToolSelectorRows(): ToolSelectorRow[] {
603
+ return [
604
+ ...FIRECRAWL_TOOL_NAMES.map((toolName) => ({ kind: "tool" as const, toolName })),
605
+ { kind: "action", action: "enableAll", label: TOOL_SELECTOR_ENABLE_ALL },
606
+ { kind: "action", action: "disableAll", label: TOOL_SELECTOR_DISABLE_ALL },
607
+ { kind: "action", action: "done", label: TOOL_SELECTOR_DONE },
608
+ ];
609
+ }
610
+
611
+ function formatToolSelectorRow(
612
+ row: ToolSelectorRow,
613
+ selectedTools: ReadonlySet<FirecrawlToolName>,
614
+ ) {
615
+ if (row.kind === "action") return row.label;
616
+ return `${selectedTools.has(row.toolName) ? "[x]" : "[ ]"} ${row.toolName}`;
617
+ }
618
+
619
+ function getActiveFirecrawlTools(pi: ExtensionAPI) {
620
+ const activeToolNames = new Set(pi.getActiveTools());
621
+ return FIRECRAWL_TOOL_NAMES.filter((toolName) => activeToolNames.has(toolName));
622
+ }
623
+
624
+ function allFirecrawlTools() {
625
+ return [...FIRECRAWL_TOOL_NAMES];
626
+ }
627
+
628
+ function orderedFirecrawlTools(selectedTools: ReadonlySet<FirecrawlToolName>) {
629
+ return FIRECRAWL_TOOL_NAMES.filter((toolName) => selectedTools.has(toolName));
630
+ }
631
+
632
+ function formatRuntimeStatus(summary: ToolStatusSummary) {
633
+ return `${summary.runtimeStatus} (${summary.activeFirecrawlToolCount}/${FIRECRAWL_TOOL_NAMES.length} active)`;
634
+ }
635
+
636
+ async function persistedSettingLabel() {
637
+ const settings = await loadSettings();
638
+ if (settings.kind === "loaded") return formatPersistedSelection(settings.settings.tools);
639
+ if (settings.kind === "invalid") {
640
+ return `none; current active-tool policy preserved (invalid settings ignored: ${settings.reason})`;
641
+ }
642
+ return "none; current active-tool policy preserved";
643
+ }
644
+
645
+ function formatPersistedSelection(tools: readonly FirecrawlToolName[]) {
646
+ if (tools.length === FIRECRAWL_TOOL_NAMES.length) {
647
+ return `all enabled (${tools.length}/${FIRECRAWL_TOOL_NAMES.length} selected)`;
648
+ }
649
+ if (tools.length === 0) return `all disabled (0/${FIRECRAWL_TOOL_NAMES.length} selected)`;
650
+ return `${tools.length}/${FIRECRAWL_TOOL_NAMES.length} selected: ${tools.join(", ")}`;
651
+ }
652
+
653
+ async function persistSettings(
654
+ ctx: CommandContext,
655
+ selectedTools: readonly FirecrawlToolName[],
656
+ ) {
657
+ try {
658
+ await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
659
+ } catch (error) {
660
+ ctx.ui.notify(`Firecrawl settings save failed: ${formatError(error)}`, "warning");
661
+ }
662
+ }
663
+
664
+ async function loadSettings(): Promise<
665
+ | { kind: "missing" }
666
+ | { kind: "invalid"; reason: string }
667
+ | { kind: "loaded"; settings: FirecrawlSettings }
668
+ > {
669
+ let text: string;
670
+ try {
671
+ text = await readFile(SETTINGS_FILE, "utf8");
672
+ } catch (error) {
673
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
674
+ return { kind: "invalid", reason: formatError(error) };
675
+ }
676
+
677
+ try {
678
+ const parsed = JSON.parse(text) as unknown;
679
+ const settings = normalizeFirecrawlSettings(parsed);
680
+ if (settings) return { kind: "loaded", settings };
681
+ return { kind: "invalid", reason: "expected tools to be an array of Firecrawl tool names" };
682
+ } catch (error) {
683
+ return { kind: "invalid", reason: formatError(error) };
684
+ }
685
+ }
686
+
687
+ function normalizeFirecrawlSettings(value: unknown): FirecrawlSettings | undefined {
688
+ if (!value || typeof value !== "object") return undefined;
689
+ const settings = value as { tools?: unknown; updatedAt?: unknown };
690
+ if (typeof settings.updatedAt !== "number") return undefined;
691
+ if (!Array.isArray(settings.tools)) return undefined;
692
+ if (!settings.tools.every(isFirecrawlToolName)) return undefined;
693
+ return { tools: orderedUniqueFirecrawlTools(settings.tools), updatedAt: settings.updatedAt };
694
+ }
695
+
696
+ function isFirecrawlToolName(value: unknown): value is FirecrawlToolName {
697
+ return typeof value === "string" && FIRECRAWL_TOOL_NAMES.includes(value as never);
698
+ }
699
+
700
+ function orderedUniqueFirecrawlTools(tools: readonly FirecrawlToolName[]) {
701
+ const selectedTools = new Set(tools);
702
+ return orderedFirecrawlTools(selectedTools);
703
+ }
704
+
705
+ async function saveSettings(settings: FirecrawlSettings) {
706
+ await mkdir(dirname(SETTINGS_FILE), { recursive: true });
707
+ const tempFile = `${SETTINGS_FILE}.${process.pid}.${Date.now()}.tmp`;
708
+ try {
709
+ await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
710
+ await rename(tempFile, SETTINGS_FILE);
711
+ } catch (error) {
712
+ await rm(tempFile, { force: true }).catch(() => undefined);
713
+ throw error;
714
+ }
715
+ }
716
+
717
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
718
+ return error instanceof Error && "code" in error;
719
+ }
720
+
721
+ function formatError(error: unknown) {
722
+ return error instanceof Error ? error.message : String(error);
723
+ }
724
+
725
+ function unique<T>(values: T[]) {
726
+ return Array.from(new Set(values));
727
+ }
728
+
216
729
  async function firecrawlRequest(
217
730
  method: "GET" | "POST",
218
731
  path: string,
@@ -292,12 +805,6 @@ async function withStatus<T>(ctx: StatusContext, status: string, callback: () =>
292
805
  }
293
806
  }
294
807
 
295
- function buildStatusMessage() {
296
- return hasApiKey()
297
- ? `Firecrawl configured: ${state.apiUrl} (API key present).`
298
- : `Firecrawl missing FIRECRAWL_API_KEY. API URL: ${state.apiUrl}.`;
299
- }
300
-
301
808
  function cleanObject<T>(value: T): T {
302
809
  if (Array.isArray(value)) {
303
810
  return value.map((item) => cleanObject(item)) as T;