@narumitw/pi-firecrawl 0.12.0 → 0.13.1
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/README.md +5 -3
- package/package.json +1 -1
- package/src/client.ts +99 -0
- package/src/firecrawl.ts +26 -792
- package/src/settings.ts +188 -0
- package/src/tool-selector.ts +330 -0
- package/src/tools.ts +186 -0
package/src/settings.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { FIRECRAWL_TOOL_NAMES, type FirecrawlToolName } from "./tools.js";
|
|
7
|
+
|
|
8
|
+
const NEW_SETTINGS_FILE = "pi-firecrawl.json";
|
|
9
|
+
const LEGACY_SETTINGS_FILE = "pi-firecrawl-settings.json";
|
|
10
|
+
|
|
11
|
+
export interface FirecrawlSettings {
|
|
12
|
+
tools: FirecrawlToolName[];
|
|
13
|
+
updatedAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SettingsLoadResult =
|
|
17
|
+
| { kind: "missing"; notice?: string }
|
|
18
|
+
| { kind: "invalid"; reason: string; notice?: string }
|
|
19
|
+
| { kind: "loaded"; settings: FirecrawlSettings; notice?: string };
|
|
20
|
+
|
|
21
|
+
type SettingsMigrationResult = {
|
|
22
|
+
kind: "migrated" | "failed";
|
|
23
|
+
notice: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function loadSettings(): Promise<SettingsLoadResult> {
|
|
27
|
+
const newPath = settingsFilePath();
|
|
28
|
+
const newSettings = await readSettingsFile(newPath);
|
|
29
|
+
if (newSettings.kind !== "missing") {
|
|
30
|
+
return withLegacyIgnoredNotice(newSettings);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const legacyPath = legacySettingsFilePath();
|
|
34
|
+
const legacySettings = await readSettingsFile(legacyPath);
|
|
35
|
+
const concurrentlyCreatedSettings = await readSettingsFile(newPath);
|
|
36
|
+
if (concurrentlyCreatedSettings.kind !== "missing") {
|
|
37
|
+
return withLegacyIgnoredNotice(concurrentlyCreatedSettings);
|
|
38
|
+
}
|
|
39
|
+
if (legacySettings.kind === "missing") return { kind: "missing" };
|
|
40
|
+
if (legacySettings.kind === "invalid") return legacySettings;
|
|
41
|
+
|
|
42
|
+
const migration = await migrateLegacySettings(legacyPath, legacySettings.settings);
|
|
43
|
+
if (migration.kind === "failed") {
|
|
44
|
+
const settingsCreatedDuringMigration = await readSettingsFile(newPath);
|
|
45
|
+
if (settingsCreatedDuringMigration.kind !== "missing") {
|
|
46
|
+
return withLegacyIgnoredNotice(settingsCreatedDuringMigration);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { ...legacySettings, notice: migration.notice };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
|
|
54
|
+
let text: string;
|
|
55
|
+
try {
|
|
56
|
+
text = await readFile(filePath, "utf8");
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
|
|
59
|
+
return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(text) as unknown;
|
|
64
|
+
const settings = normalizeFirecrawlSettings(parsed);
|
|
65
|
+
if (settings) return { kind: "loaded", settings };
|
|
66
|
+
return {
|
|
67
|
+
kind: "invalid",
|
|
68
|
+
reason: `${filePath}: expected tools to be an array of Firecrawl tool names`,
|
|
69
|
+
};
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
|
|
76
|
+
if (!(await fileExists(legacySettingsFilePath()))) return settings;
|
|
77
|
+
return {
|
|
78
|
+
...settings,
|
|
79
|
+
notice: `Firecrawl legacy settings ignored: ${legacySettingsFilePath()} exists, but ${settingsFilePath()} takes precedence. Delete ${LEGACY_SETTINGS_FILE} after confirming your settings.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function installSettingsFileExclusively(filePath: string, contents: string) {
|
|
84
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
85
|
+
const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
86
|
+
try {
|
|
87
|
+
await writeFile(tempFile, contents, { encoding: "utf8", flag: "wx" });
|
|
88
|
+
await link(tempFile, filePath);
|
|
89
|
+
} finally {
|
|
90
|
+
await rm(tempFile, { force: true }).catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function migrateLegacySettings(
|
|
95
|
+
legacyPath: string,
|
|
96
|
+
settings: FirecrawlSettings,
|
|
97
|
+
): Promise<SettingsMigrationResult> {
|
|
98
|
+
const newPath = settingsFilePath();
|
|
99
|
+
try {
|
|
100
|
+
await installSettingsFileExclusively(
|
|
101
|
+
newPath,
|
|
102
|
+
`${JSON.stringify(settings, null, 2)}\n`,
|
|
103
|
+
);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return {
|
|
106
|
+
kind: "failed",
|
|
107
|
+
notice: `Firecrawl legacy settings migration failed: could not migrate ${legacyPath} to ${newPath}: ${formatError(error)}. The legacy file was used for this session; future saves will write ${NEW_SETTINGS_FILE}.`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
await rm(legacyPath, { force: true });
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return {
|
|
115
|
+
kind: "migrated",
|
|
116
|
+
notice: `Firecrawl settings migrated from ${legacyPath} to ${newPath}, but the legacy file could not be removed: ${formatError(error)}. Delete ${LEGACY_SETTINGS_FILE} after confirming your settings.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
kind: "migrated",
|
|
122
|
+
notice: `Firecrawl settings migrated from ${legacyPath} to ${newPath}. ${LEGACY_SETTINGS_FILE} is deprecated and will be removed in a future major release.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function fileExists(filePath: string) {
|
|
127
|
+
try {
|
|
128
|
+
await access(filePath, constants.F_OK);
|
|
129
|
+
return true;
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function normalizeFirecrawlSettings(value: unknown): FirecrawlSettings | undefined {
|
|
136
|
+
if (!value || typeof value !== "object") return undefined;
|
|
137
|
+
const settings = value as { tools?: unknown; updatedAt?: unknown };
|
|
138
|
+
if (typeof settings.updatedAt !== "number") return undefined;
|
|
139
|
+
if (!Array.isArray(settings.tools)) return undefined;
|
|
140
|
+
if (!settings.tools.every(isFirecrawlToolName)) return undefined;
|
|
141
|
+
return { tools: orderedUniqueFirecrawlTools(settings.tools), updatedAt: settings.updatedAt };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isFirecrawlToolName(value: unknown): value is FirecrawlToolName {
|
|
145
|
+
return typeof value === "string" && FIRECRAWL_TOOL_NAMES.includes(value as never);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function orderedUniqueFirecrawlTools(tools: readonly FirecrawlToolName[]) {
|
|
149
|
+
const selectedTools = new Set(tools);
|
|
150
|
+
return FIRECRAWL_TOOL_NAMES.filter((toolName) => selectedTools.has(toolName));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function saveSettings(settings: FirecrawlSettings) {
|
|
154
|
+
const filePath = settingsFilePath();
|
|
155
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
156
|
+
const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
157
|
+
try {
|
|
158
|
+
await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
159
|
+
await rename(tempFile, filePath);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
await rm(tempFile, { force: true }).catch(() => undefined);
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function settingsFilePath() {
|
|
167
|
+
return join(agentDir(), NEW_SETTINGS_FILE);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function legacySettingsFilePath() {
|
|
171
|
+
return join(agentDir(), LEGACY_SETTINGS_FILE);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function agentDir() {
|
|
175
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
179
|
+
return error instanceof Error && "code" in error;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function formatError(error: unknown) {
|
|
183
|
+
return error instanceof Error ? error.message : String(error);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function unique<T>(values: T[]) {
|
|
187
|
+
return Array.from(new Set(values));
|
|
188
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { configuredApiUrl, hasApiKey } from "./client.js";
|
|
3
|
+
import { loadSettings, saveSettings, settingsFilePath, type SettingsLoadResult } from "./settings.js";
|
|
4
|
+
import { FIRECRAWL_TOOL_NAMES, type FirecrawlToolName } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
type CommandContext = ExtensionCommandContext;
|
|
7
|
+
|
|
8
|
+
const TOOL_SELECTOR_DONE = "Done";
|
|
9
|
+
const TOOL_SELECTOR_ENABLE_ALL = "Enable all Firecrawl tools";
|
|
10
|
+
const TOOL_SELECTOR_DISABLE_ALL = "Disable all Firecrawl tools";
|
|
11
|
+
type ToolRuntimeStatus = "enabled" | "disabled" | "partial";
|
|
12
|
+
type ToolSelectorAction = "enableAll" | "disableAll" | "done";
|
|
13
|
+
type ToolSelectorRow =
|
|
14
|
+
| { kind: "tool"; toolName: FirecrawlToolName }
|
|
15
|
+
| { kind: "action"; action: ToolSelectorAction; label: string };
|
|
16
|
+
interface ToolStatusSummary {
|
|
17
|
+
runtimeStatus: ToolRuntimeStatus;
|
|
18
|
+
activeFirecrawlToolCount: number;
|
|
19
|
+
activeNonFirecrawlToolCount: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let settingsNotice: string | undefined;
|
|
23
|
+
|
|
24
|
+
export function clearSettingsNotice() {
|
|
25
|
+
settingsNotice = undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function recordSettingsNotice(settings: SettingsLoadResult) {
|
|
29
|
+
if (settings.notice) settingsNotice = settings.notice;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function showToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
|
|
33
|
+
if (!ctx.hasUI) {
|
|
34
|
+
ctx.ui.notify(
|
|
35
|
+
`Firecrawl tool selection needs an interactive UI.\n\n${await buildStatusMessage(pi)}`,
|
|
36
|
+
hasApiKey() ? "info" : "warning",
|
|
37
|
+
);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let selectedTools = new Set<FirecrawlToolName>(getActiveFirecrawlTools(pi));
|
|
42
|
+
let persistQueue = Promise.resolve();
|
|
43
|
+
const commitSelectedTools = () => {
|
|
44
|
+
const nextSelectedTools = orderedFirecrawlTools(selectedTools);
|
|
45
|
+
applyFirecrawlTools(pi, nextSelectedTools);
|
|
46
|
+
persistQueue = persistQueue.then(() => persistSettings(ctx, nextSelectedTools));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const customResult = await ctx.ui.custom<"closed" | undefined>(
|
|
50
|
+
(tui, theme, keybindings, done) => {
|
|
51
|
+
const rows = firecrawlToolSelectorRows();
|
|
52
|
+
let selectedIndex = 0;
|
|
53
|
+
const moveSelection = (delta: number) => {
|
|
54
|
+
selectedIndex = (selectedIndex + delta + rows.length) % rows.length;
|
|
55
|
+
};
|
|
56
|
+
const activateSelectedRow = () => {
|
|
57
|
+
const row = rows[selectedIndex];
|
|
58
|
+
if (!row) return;
|
|
59
|
+
|
|
60
|
+
if (row.kind === "tool") {
|
|
61
|
+
if (selectedTools.has(row.toolName)) selectedTools.delete(row.toolName);
|
|
62
|
+
else selectedTools.add(row.toolName);
|
|
63
|
+
commitSelectedTools();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (row.action === "enableAll") {
|
|
68
|
+
selectedTools = new Set(allFirecrawlTools());
|
|
69
|
+
commitSelectedTools();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (row.action === "disableAll") {
|
|
73
|
+
selectedTools = new Set();
|
|
74
|
+
commitSelectedTools();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
done("closed");
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
invalidate() {},
|
|
83
|
+
render() {
|
|
84
|
+
return [
|
|
85
|
+
theme.fg("accent", theme.bold(toolSelectorTitle(selectedTools))),
|
|
86
|
+
"",
|
|
87
|
+
...rows.map((row, index) => {
|
|
88
|
+
const label = formatToolSelectorRow(row, selectedTools);
|
|
89
|
+
if (index === selectedIndex) {
|
|
90
|
+
return `${theme.fg("accent", "›")} ${theme.fg("accent", label)}`;
|
|
91
|
+
}
|
|
92
|
+
return ` ${label}`;
|
|
93
|
+
}),
|
|
94
|
+
"",
|
|
95
|
+
theme.fg("dim", "↑↓ navigate • Enter/Space toggle • Esc close"),
|
|
96
|
+
];
|
|
97
|
+
},
|
|
98
|
+
handleInput(data: string) {
|
|
99
|
+
if (keybindings.matches(data, "tui.select.up")) {
|
|
100
|
+
moveSelection(-1);
|
|
101
|
+
tui.requestRender();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (keybindings.matches(data, "tui.select.down")) {
|
|
105
|
+
moveSelection(1);
|
|
106
|
+
tui.requestRender();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (keybindings.matches(data, "tui.select.pageUp")) {
|
|
110
|
+
selectedIndex = 0;
|
|
111
|
+
tui.requestRender();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (keybindings.matches(data, "tui.select.pageDown")) {
|
|
115
|
+
selectedIndex = rows.length - 1;
|
|
116
|
+
tui.requestRender();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (keybindings.matches(data, "tui.select.confirm") || data === " ") {
|
|
120
|
+
activateSelectedRow();
|
|
121
|
+
tui.requestRender();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
125
|
+
done("closed");
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (customResult !== "closed") {
|
|
133
|
+
await showDialogToolSelector(pi, ctx);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await persistQueue;
|
|
138
|
+
ctx.ui.notify(await buildStatusMessage(pi), hasApiKey() ? "info" : "warning");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function showDialogToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
|
|
142
|
+
let selectedTools = new Set<FirecrawlToolName>(getActiveFirecrawlTools(pi));
|
|
143
|
+
while (true) {
|
|
144
|
+
const rows = firecrawlToolSelectorRows();
|
|
145
|
+
const choices = rows.map((row) => formatToolSelectorRow(row, selectedTools));
|
|
146
|
+
const choice = await ctx.ui.select(toolSelectorTitle(selectedTools), choices);
|
|
147
|
+
if (!choice) break;
|
|
148
|
+
|
|
149
|
+
const row = rows[choices.indexOf(choice)];
|
|
150
|
+
if (!row) continue;
|
|
151
|
+
if (row.kind === "action" && row.action === "done") break;
|
|
152
|
+
|
|
153
|
+
if (row.kind === "tool") {
|
|
154
|
+
if (selectedTools.has(row.toolName)) selectedTools.delete(row.toolName);
|
|
155
|
+
else selectedTools.add(row.toolName);
|
|
156
|
+
} else if (row.action === "enableAll") {
|
|
157
|
+
selectedTools = new Set(allFirecrawlTools());
|
|
158
|
+
} else if (row.action === "disableAll") {
|
|
159
|
+
selectedTools = new Set();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await setSelectedFirecrawlTools(pi, ctx, orderedFirecrawlTools(selectedTools));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
ctx.ui.notify(await buildStatusMessage(pi), hasApiKey() ? "info" : "warning");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function updateFirecrawlTools(
|
|
169
|
+
pi: ExtensionAPI,
|
|
170
|
+
ctx: CommandContext,
|
|
171
|
+
selectedTools: readonly FirecrawlToolName[],
|
|
172
|
+
action: string,
|
|
173
|
+
) {
|
|
174
|
+
await setSelectedFirecrawlTools(pi, ctx, selectedTools);
|
|
175
|
+
ctx.ui.notify(
|
|
176
|
+
`Firecrawl tools ${action}.\n\n${await buildStatusMessage(pi)}`,
|
|
177
|
+
hasApiKey() ? "info" : "warning",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function setSelectedFirecrawlTools(
|
|
182
|
+
pi: ExtensionAPI,
|
|
183
|
+
ctx: CommandContext,
|
|
184
|
+
selectedTools: readonly FirecrawlToolName[],
|
|
185
|
+
) {
|
|
186
|
+
applyFirecrawlTools(pi, selectedTools);
|
|
187
|
+
await persistSettings(ctx, selectedTools);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function applyFirecrawlTools(pi: ExtensionAPI, selectedTools: readonly FirecrawlToolName[]) {
|
|
191
|
+
const activeToolNames = pi.getActiveTools();
|
|
192
|
+
const firecrawlToolNames = new Set<string>(FIRECRAWL_TOOL_NAMES);
|
|
193
|
+
const activeNonFirecrawlToolNames = activeToolNames.filter(
|
|
194
|
+
(name) => !firecrawlToolNames.has(name),
|
|
195
|
+
);
|
|
196
|
+
pi.setActiveTools(unique([...activeNonFirecrawlToolNames, ...selectedTools]));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getToolStatusSummary(pi: ExtensionAPI): ToolStatusSummary {
|
|
200
|
+
const firecrawlToolNames = new Set<string>(FIRECRAWL_TOOL_NAMES);
|
|
201
|
+
const activeToolNames = new Set(pi.getActiveTools());
|
|
202
|
+
const activeFirecrawlToolCount = FIRECRAWL_TOOL_NAMES.filter((name) =>
|
|
203
|
+
activeToolNames.has(name),
|
|
204
|
+
).length;
|
|
205
|
+
const activeNonFirecrawlToolCount = Array.from(activeToolNames).filter(
|
|
206
|
+
(name) => !firecrawlToolNames.has(name),
|
|
207
|
+
).length;
|
|
208
|
+
const runtimeStatus =
|
|
209
|
+
activeFirecrawlToolCount === FIRECRAWL_TOOL_NAMES.length
|
|
210
|
+
? "enabled"
|
|
211
|
+
: activeFirecrawlToolCount === 0
|
|
212
|
+
? "disabled"
|
|
213
|
+
: "partial";
|
|
214
|
+
|
|
215
|
+
return { runtimeStatus, activeFirecrawlToolCount, activeNonFirecrawlToolCount };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function buildStatusMessage(pi: ExtensionAPI) {
|
|
219
|
+
const summary = getToolStatusSummary(pi);
|
|
220
|
+
const persistedSetting = await persistedSettingLabel();
|
|
221
|
+
return [
|
|
222
|
+
`Firecrawl tools: ${formatRuntimeStatus(summary)}`,
|
|
223
|
+
`Persisted selection: ${persistedSetting}`,
|
|
224
|
+
`Settings file: ${settingsFilePath()}`,
|
|
225
|
+
...(settingsNotice ? [`Settings note: ${settingsNotice}`] : []),
|
|
226
|
+
`Other active tools preserved: ${summary.activeNonFirecrawlToolCount}`,
|
|
227
|
+
`API key: ${hasApiKey() ? "present" : "missing"} (FIRECRAWL_API_KEY)`,
|
|
228
|
+
`API URL: ${configuredApiUrl()}`,
|
|
229
|
+
].join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function buildConfigMessage() {
|
|
233
|
+
return [
|
|
234
|
+
"Firecrawl configuration:",
|
|
235
|
+
`API key: ${hasApiKey() ? "present" : "missing"} (FIRECRAWL_API_KEY)`,
|
|
236
|
+
`API URL: ${configuredApiUrl()}`,
|
|
237
|
+
"Override API URL with FIRECRAWL_API_URL or FIRECRAWL_BASE_URL.",
|
|
238
|
+
"This extension never logs, displays, or stores your Firecrawl API key.",
|
|
239
|
+
].join("\n");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function buildCommandGuide() {
|
|
243
|
+
return [
|
|
244
|
+
"Firecrawl commands:",
|
|
245
|
+
"/firecrawl — open this menu",
|
|
246
|
+
"/firecrawl help — show command usage",
|
|
247
|
+
"/firecrawl config — show API key presence and API URL",
|
|
248
|
+
"/firecrawl quickstart — alias for /firecrawl config",
|
|
249
|
+
"/firecrawl status — show tool and settings status",
|
|
250
|
+
"/firecrawl tools — select individual Firecrawl tools",
|
|
251
|
+
"/firecrawl toggle — alias for /firecrawl tools",
|
|
252
|
+
"/firecrawl enable — enable all Firecrawl tools",
|
|
253
|
+
"/firecrawl disable — disable all Firecrawl tools",
|
|
254
|
+
].join("\n");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function toolSelectorTitle(selectedTools: ReadonlySet<FirecrawlToolName>) {
|
|
258
|
+
return `Firecrawl tools (${selectedTools.size}/${FIRECRAWL_TOOL_NAMES.length}). Non-built-in tools run at user risk.`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function firecrawlToolSelectorRows(): ToolSelectorRow[] {
|
|
262
|
+
return [
|
|
263
|
+
...FIRECRAWL_TOOL_NAMES.map((toolName) => ({ kind: "tool" as const, toolName })),
|
|
264
|
+
{ kind: "action", action: "enableAll", label: TOOL_SELECTOR_ENABLE_ALL },
|
|
265
|
+
{ kind: "action", action: "disableAll", label: TOOL_SELECTOR_DISABLE_ALL },
|
|
266
|
+
{ kind: "action", action: "done", label: TOOL_SELECTOR_DONE },
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function formatToolSelectorRow(
|
|
271
|
+
row: ToolSelectorRow,
|
|
272
|
+
selectedTools: ReadonlySet<FirecrawlToolName>,
|
|
273
|
+
) {
|
|
274
|
+
if (row.kind === "action") return row.label;
|
|
275
|
+
return `${selectedTools.has(row.toolName) ? "[x]" : "[ ]"} ${row.toolName}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function getActiveFirecrawlTools(pi: ExtensionAPI) {
|
|
279
|
+
const activeToolNames = new Set(pi.getActiveTools());
|
|
280
|
+
return FIRECRAWL_TOOL_NAMES.filter((toolName) => activeToolNames.has(toolName));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function allFirecrawlTools() {
|
|
284
|
+
return [...FIRECRAWL_TOOL_NAMES];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function unique<T>(values: T[]) {
|
|
288
|
+
return Array.from(new Set(values));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function orderedFirecrawlTools(selectedTools: ReadonlySet<FirecrawlToolName>) {
|
|
292
|
+
return FIRECRAWL_TOOL_NAMES.filter((toolName) => selectedTools.has(toolName));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function formatRuntimeStatus(summary: ToolStatusSummary) {
|
|
296
|
+
return `${summary.runtimeStatus} (${summary.activeFirecrawlToolCount}/${FIRECRAWL_TOOL_NAMES.length} active)`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function persistedSettingLabel() {
|
|
300
|
+
const settings = await loadSettings();
|
|
301
|
+
recordSettingsNotice(settings);
|
|
302
|
+
if (settings.kind === "loaded") return formatPersistedSelection(settings.settings.tools);
|
|
303
|
+
if (settings.kind === "invalid") {
|
|
304
|
+
return `none; current active-tool policy preserved (invalid settings ignored: ${settings.reason})`;
|
|
305
|
+
}
|
|
306
|
+
return "none; current active-tool policy preserved";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function formatPersistedSelection(tools: readonly FirecrawlToolName[]) {
|
|
310
|
+
if (tools.length === FIRECRAWL_TOOL_NAMES.length) {
|
|
311
|
+
return `all enabled (${tools.length}/${FIRECRAWL_TOOL_NAMES.length} selected)`;
|
|
312
|
+
}
|
|
313
|
+
if (tools.length === 0) return `all disabled (0/${FIRECRAWL_TOOL_NAMES.length} selected)`;
|
|
314
|
+
return `${tools.length}/${FIRECRAWL_TOOL_NAMES.length} selected: ${tools.join(", ")}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function formatError(error: unknown) {
|
|
318
|
+
return error instanceof Error ? error.message : String(error);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function persistSettings(
|
|
322
|
+
ctx: CommandContext,
|
|
323
|
+
selectedTools: readonly FirecrawlToolName[],
|
|
324
|
+
) {
|
|
325
|
+
try {
|
|
326
|
+
await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
|
|
327
|
+
} catch (error) {
|
|
328
|
+
ctx.ui.notify(`Firecrawl settings save failed: ${formatError(error)}`, "warning");
|
|
329
|
+
}
|
|
330
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { cleanObject, firecrawlRequest, jsonResult, withStatus } from "./client.js";
|
|
4
|
+
|
|
5
|
+
export const FIRECRAWL_TOOL_NAMES = [
|
|
6
|
+
"firecrawl_scrape",
|
|
7
|
+
"firecrawl_crawl",
|
|
8
|
+
"firecrawl_crawl_status",
|
|
9
|
+
"firecrawl_map",
|
|
10
|
+
"firecrawl_search",
|
|
11
|
+
] as const;
|
|
12
|
+
export type FirecrawlToolName = (typeof FIRECRAWL_TOOL_NAMES)[number];
|
|
13
|
+
|
|
14
|
+
const StringArray = Type.Array(Type.String());
|
|
15
|
+
|
|
16
|
+
export const scrapeTool = defineTool({
|
|
17
|
+
name: FIRECRAWL_TOOL_NAMES[0],
|
|
18
|
+
label: "Firecrawl: Scrape",
|
|
19
|
+
description: "Scrape a single URL through Firecrawl and return requested formats.",
|
|
20
|
+
promptSnippet: "Scrape a URL through Firecrawl",
|
|
21
|
+
promptGuidelines: [
|
|
22
|
+
"Use firecrawl_scrape when you need clean markdown, HTML, links, screenshots, or structured extraction for one URL.",
|
|
23
|
+
"If FIRECRAWL_API_KEY is missing, report the configuration error instead of retrying repeatedly.",
|
|
24
|
+
],
|
|
25
|
+
parameters: Type.Object({
|
|
26
|
+
url: Type.String({ description: "URL to scrape." }),
|
|
27
|
+
formats: Type.Optional(
|
|
28
|
+
Type.Array(
|
|
29
|
+
Type.String({
|
|
30
|
+
description:
|
|
31
|
+
"Requested Firecrawl output format, such as markdown, html, rawHtml, links, screenshot, or json.",
|
|
32
|
+
}),
|
|
33
|
+
{ description: "Firecrawl output formats. Defaults to Firecrawl's API default." },
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
onlyMainContent: Type.Optional(
|
|
37
|
+
Type.Boolean({ description: "Return only the main page content when supported." }),
|
|
38
|
+
),
|
|
39
|
+
includeTags: Type.Optional(StringArray),
|
|
40
|
+
excludeTags: Type.Optional(StringArray),
|
|
41
|
+
waitFor: Type.Optional(Type.Number({ description: "Milliseconds to wait before scraping." })),
|
|
42
|
+
timeout: Type.Optional(
|
|
43
|
+
Type.Number({ description: "Firecrawl request timeout in milliseconds." }),
|
|
44
|
+
),
|
|
45
|
+
mobile: Type.Optional(Type.Boolean({ description: "Use a mobile user agent when supported." })),
|
|
46
|
+
skipTlsVerification: Type.Optional(
|
|
47
|
+
Type.Boolean({ description: "Skip TLS certificate verification when supported." }),
|
|
48
|
+
),
|
|
49
|
+
removeBase64Images: Type.Optional(
|
|
50
|
+
Type.Boolean({ description: "Remove base64 image data from the response when supported." }),
|
|
51
|
+
),
|
|
52
|
+
blockAds: Type.Optional(
|
|
53
|
+
Type.Boolean({ description: "Block ads while scraping when supported." }),
|
|
54
|
+
),
|
|
55
|
+
headers: Type.Optional(
|
|
56
|
+
Type.Record(Type.String(), Type.String(), {
|
|
57
|
+
description: "Additional HTTP headers Firecrawl should use while fetching the target URL.",
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
jsonOptions: Type.Optional(
|
|
61
|
+
Type.Any({ description: "Firecrawl jsonOptions for structured extraction." }),
|
|
62
|
+
),
|
|
63
|
+
actions: Type.Optional(
|
|
64
|
+
Type.Array(Type.Any(), {
|
|
65
|
+
description: "Firecrawl browser actions to perform before scraping.",
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
location: Type.Optional(Type.Any({ description: "Firecrawl location options." })),
|
|
69
|
+
}),
|
|
70
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
71
|
+
return withStatus(ctx, "scrape", async () => {
|
|
72
|
+
const payload = await firecrawlRequest("POST", "/scrape", cleanObject(params), signal);
|
|
73
|
+
return jsonResult(payload);
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export const crawlTool = defineTool({
|
|
79
|
+
name: FIRECRAWL_TOOL_NAMES[1],
|
|
80
|
+
label: "Firecrawl: Crawl",
|
|
81
|
+
description: "Start a Firecrawl crawl job for a website.",
|
|
82
|
+
promptSnippet: "Start a Firecrawl site crawl job",
|
|
83
|
+
parameters: Type.Object({
|
|
84
|
+
url: Type.String({ description: "Starting URL for the crawl." }),
|
|
85
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of pages to crawl." })),
|
|
86
|
+
maxDepth: Type.Optional(Type.Number({ description: "Maximum crawl depth when supported." })),
|
|
87
|
+
includePaths: Type.Optional(
|
|
88
|
+
Type.Array(Type.String(), { description: "URL path patterns to include." }),
|
|
89
|
+
),
|
|
90
|
+
excludePaths: Type.Optional(
|
|
91
|
+
Type.Array(Type.String(), { description: "URL path patterns to exclude." }),
|
|
92
|
+
),
|
|
93
|
+
allowBackwardLinks: Type.Optional(
|
|
94
|
+
Type.Boolean({ description: "Allow crawling backward links when supported." }),
|
|
95
|
+
),
|
|
96
|
+
allowExternalLinks: Type.Optional(
|
|
97
|
+
Type.Boolean({ description: "Allow crawling external links when supported." }),
|
|
98
|
+
),
|
|
99
|
+
ignoreSitemap: Type.Optional(Type.Boolean({ description: "Ignore sitemap discovery." })),
|
|
100
|
+
deduplicateSimilarURLs: Type.Optional(
|
|
101
|
+
Type.Boolean({ description: "Deduplicate similar URLs when supported." }),
|
|
102
|
+
),
|
|
103
|
+
scrapeOptions: Type.Optional(
|
|
104
|
+
Type.Any({ description: "Firecrawl scrapeOptions applied to crawled pages." }),
|
|
105
|
+
),
|
|
106
|
+
webhook: Type.Optional(Type.Any({ description: "Firecrawl webhook configuration." })),
|
|
107
|
+
}),
|
|
108
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
109
|
+
return withStatus(ctx, "crawl", async () => {
|
|
110
|
+
const payload = await firecrawlRequest("POST", "/crawl", cleanObject(params), signal);
|
|
111
|
+
return jsonResult(payload);
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const crawlStatusTool = defineTool({
|
|
117
|
+
name: FIRECRAWL_TOOL_NAMES[2],
|
|
118
|
+
label: "Firecrawl: Crawl Status",
|
|
119
|
+
description: "Check a Firecrawl crawl job status and retrieve completed crawl data.",
|
|
120
|
+
promptSnippet: "Check a Firecrawl crawl job status",
|
|
121
|
+
parameters: Type.Object({
|
|
122
|
+
id: Type.String({ description: "Crawl job id returned by firecrawl_crawl." }),
|
|
123
|
+
}),
|
|
124
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
125
|
+
return withStatus(ctx, "crawl status", async () => {
|
|
126
|
+
const payload = await firecrawlRequest(
|
|
127
|
+
"GET",
|
|
128
|
+
`/crawl/${encodeURIComponent(params.id)}`,
|
|
129
|
+
undefined,
|
|
130
|
+
signal,
|
|
131
|
+
);
|
|
132
|
+
return jsonResult(payload);
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export const mapTool = defineTool({
|
|
138
|
+
name: FIRECRAWL_TOOL_NAMES[3],
|
|
139
|
+
label: "Firecrawl: Map",
|
|
140
|
+
description: "Discover URLs for a site through Firecrawl's map endpoint.",
|
|
141
|
+
promptSnippet: "Map/discover URLs for a site through Firecrawl",
|
|
142
|
+
parameters: Type.Object({
|
|
143
|
+
url: Type.String({ description: "Website URL to map." }),
|
|
144
|
+
search: Type.Optional(
|
|
145
|
+
Type.String({ description: "Optional search term to filter discovered URLs." }),
|
|
146
|
+
),
|
|
147
|
+
ignoreSitemap: Type.Optional(Type.Boolean({ description: "Ignore sitemap discovery." })),
|
|
148
|
+
sitemapOnly: Type.Optional(
|
|
149
|
+
Type.Boolean({ description: "Only use sitemap URLs when supported." }),
|
|
150
|
+
),
|
|
151
|
+
includeSubdomains: Type.Optional(
|
|
152
|
+
Type.Boolean({ description: "Include subdomains when supported." }),
|
|
153
|
+
),
|
|
154
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of URLs to return." })),
|
|
155
|
+
}),
|
|
156
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
157
|
+
return withStatus(ctx, "map", async () => {
|
|
158
|
+
const payload = await firecrawlRequest("POST", "/map", cleanObject(params), signal);
|
|
159
|
+
return jsonResult(payload);
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
export const searchTool = defineTool({
|
|
165
|
+
name: FIRECRAWL_TOOL_NAMES[4],
|
|
166
|
+
label: "Firecrawl: Search",
|
|
167
|
+
description: "Search the web through Firecrawl and optionally scrape search results.",
|
|
168
|
+
promptSnippet: "Search the web through Firecrawl",
|
|
169
|
+
parameters: Type.Object({
|
|
170
|
+
query: Type.String({ description: "Search query." }),
|
|
171
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of search results." })),
|
|
172
|
+
tbs: Type.Optional(
|
|
173
|
+
Type.String({ description: "Google-style time based search filter when supported." }),
|
|
174
|
+
),
|
|
175
|
+
location: Type.Optional(Type.String({ description: "Search location when supported." })),
|
|
176
|
+
scrapeOptions: Type.Optional(
|
|
177
|
+
Type.Any({ description: "Firecrawl scrapeOptions for search result pages." }),
|
|
178
|
+
),
|
|
179
|
+
}),
|
|
180
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
181
|
+
return withStatus(ctx, "search", async () => {
|
|
182
|
+
const payload = await firecrawlRequest("POST", "/search", cleanObject(params), signal);
|
|
183
|
+
return jsonResult(payload);
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
});
|