From fe5218572b38ce81b46a7d90d0be640bd2e0d27c Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:38:01 +0000 Subject: [PATCH 01/16] fix(setup, init): skip unavailable clients in --agent all and only offer downloadable templates --- README.md | 7 ++ package.json | 2 +- src/__tests__/commands/init.test.ts | 43 ++++++- src/__tests__/commands/setup.test.ts | 112 ++++++++++++++++- src/commands/init.ts | 178 ++++++--------------------- src/commands/setup.ts | 110 ++++++++++++++--- src/index.ts | 5 +- 7 files changed, 297 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index 33d0e88639..a860d205a5 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,13 @@ To install the Firecrawl MCP server into your editors (Cursor, Claude Code, VS C firecrawl setup mcp ``` +Use `--agent ` for one client, or `--agent all --global` to configure +Claude Code, VS Code, Codex, OpenCode, Cursor, Gemini CLI, Windsurf, +Antigravity, Hermes Agent, and OpenClaw in one run. Clients that cannot be +configured on this machine (for example OpenClaw when the `openclaw` command is +not installed) are skipped and listed in the summary; the command only fails +when a client it tried to configure fails. + To make Firecrawl the default web provider for supported AI agents: ```bash diff --git a/package.json b/package.json index e99757fe02..72717feef2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.26.9", + "version": "1.26.10", "publishConfig": { "tag": "latest" }, diff --git a/src/__tests__/commands/init.test.ts b/src/__tests__/commands/init.test.ts index 8bd9dbbbf2..f8af520c35 100644 --- a/src/__tests__/commands/init.test.ts +++ b/src/__tests__/commands/init.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { execSync } from 'child_process'; import * as skillsNative from '../../commands/skills-native'; -import { handleInitCommand } from '../../commands/init'; +import { + TEMPLATES, + findTemplate, + handleInitCommand, + scaffoldTemplate, +} from '../../commands/init'; import { CLI_SKILLS, WORKFLOW_SKILLS } from '../../commands/skills-install'; const cliSkillFlags = `--skill ${CLI_SKILLS.join(' ')}`; @@ -301,3 +306,39 @@ describe('init global installation', () => { } ); }); + +describe('init templates', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('only lists templates that are standalone public repositories', () => { + expect(TEMPLATES.length).toBeGreaterThan(0); + for (const template of TEMPLATES) { + expect(template.repo).toMatch(/^firecrawl\/[\w.-]+$/); + } + expect(findTemplate('open-lovable')?.repo).toBe('firecrawl/open-lovable'); + }); + + it('rejects a template that is no longer offered without downloading anything', async () => { + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`); + }) as never); + + expect(findTemplate('scrape-basic')).toBeUndefined(); + await expect(scaffoldTemplate('scrape-basic')).rejects.toThrow('exit 1'); + expect(execSync).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Unknown template') + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('open-lovable') + ); + }); +}); diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 036a7bc2dd..53b7e91dc4 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -30,6 +30,16 @@ const cliSkillFlags = `--skill ${CLI_SKILLS.join(' ')}`; const buildSkillFlags = `--skill ${BUILD_SKILLS.join(' ')}`; const workflowSkillFlags = `--skill ${WORKFLOW_SKILLS.join(' ')}`; +/** Create a bin directory holding empty stand-ins for the given commands. */ +function stubCommands(root: string, commands: string[]): string { + const bin = path.join(root, 'bin'); + mkdirSync(bin, { recursive: true }); + for (const command of commands) { + writeFileSync(path.join(bin, command), '', { mode: 0o755 }); + } + return bin; +} + function expectSkillsInstall(command: string) { const [bin, ...args] = command.split(' '); expect(execFileSync).toHaveBeenCalledWith( @@ -587,8 +597,10 @@ describe('handleSetupCommand', () => { }); it('uses each client native environment binding with --agent all', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-env-test-')); + const originalPath = process.env.PATH; process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + process.env.PATH = stubCommands(home, ['codex', 'openclaw']); try { await handleSetupCommand('mcp', { @@ -617,6 +629,104 @@ describe('handleSetupCommand', () => { readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') ).toContain('Authorization: Bearer ${FIRECRAWL_API_KEY}'); } finally { + process.env.PATH = originalPath; + rmSync(home, { recursive: true, force: true }); + } + }); + + it('skips clients that are not installed with --agent all and still succeeds', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-skip-')); + const originalPath = process.env.PATH; + process.env.HOME = home; + process.env.PATH = stubCommands(home, []); + vi.mocked(getApiKey).mockReturnValue(undefined); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleSetupCommand('mcp', { + agent: 'all', + global: true, + yes: true, + }); + + const commands = vi.mocked(execFileSync).mock.calls.map(([cmd]) => cmd); + expect(commands).not.toContain('openclaw'); + expect(commands.filter((cmd) => cmd === 'npx')).toHaveLength(8); + expect(existsSync(path.join(home, '.hermes', 'config.yaml'))).toBe(true); + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('Skipping OpenClaw: openclaw not found on PATH'); + expect(output).toMatch(/Configured: .*Gemini CLI.*Hermes Agent/); + expect(output).toContain( + 'Skipped: OpenClaw (openclaw not found on PATH)' + ); + } finally { + process.env.PATH = originalPath; + rmSync(home, { recursive: true, force: true }); + } + }); + + it('skips clients without a verified environment binding with --agent all', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-env-skip-')); + const originalPath = process.env.PATH; + process.env.HOME = home; + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + process.env.PATH = stubCommands(home, []); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleSetupCommand('mcp', { + agent: 'all', + global: true, + yes: true, + }); + + const serialized = vi + .mocked(execFileSync) + .mock.calls.map(([cmd, args]) => + [cmd, ...(args as string[])].join(' ') + ); + expect(serialized.some((call) => call.includes('antigravity'))).toBe( + false + ); + expect(serialized.some((call) => call.startsWith('codex '))).toBe(false); + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('Skipping Codex: codex not found on PATH'); + expect(output).toContain( + 'Skipping Antigravity: no verified environment-variable syntax' + ); + } finally { + process.env.PATH = originalPath; + rmSync(home, { recursive: true, force: true }); + } + }); + + it('keeps configuring other clients and fails when an installed client fails', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-fail-')); + const originalPath = process.env.PATH; + process.env.HOME = home; + process.env.PATH = stubCommands(home, ['openclaw']); + vi.mocked(getApiKey).mockReturnValue(undefined); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(execFileSync).mockImplementation((_cmd, args) => { + if ((args as string[]).includes('cursor')) { + throw new Error('add-mcp failed'); + } + return Buffer.from(''); + }); + + try { + await expect( + handleSetupCommand('mcp', { agent: 'all', global: true, yes: true }) + ).rejects.toThrow('Failed to configure Firecrawl MCP for Cursor.'); + + const commands = vi.mocked(execFileSync).mock.calls.map(([cmd]) => cmd); + expect(commands.filter((cmd) => cmd === 'npx')).toHaveLength(8); + expect(commands).toContain('openclaw'); + expect(existsSync(path.join(home, '.hermes', 'config.yaml'))).toBe(true); + } finally { + vi.mocked(execFileSync).mockReset(); + process.env.PATH = originalPath; rmSync(home, { recursive: true, force: true }); } }); @@ -666,7 +776,7 @@ describe('handleSetupCommand', () => { .mock.calls.filter(([, args]) => (args as string[])?.includes('add-mcp@1.14.0') ); - expect(addMcpCalls).toHaveLength(5); + expect(addMcpCalls).toHaveLength(8); expect(addMcpCalls.flat(2)).not.toContain('--global'); expect( readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') diff --git a/src/commands/init.ts b/src/commands/init.ts index 6fe9718135..774a91e279 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -52,67 +52,18 @@ const dim = '\x1b[2m'; const bold = '\x1b[1m'; const green = '\x1b[32m'; -const TEMPLATES_REPO = 'firecrawl/cli-templates'; - interface TemplateEntry { name: string; description: string; - path: string; // subdirectory within the templates repo + /** Public GitHub repository (owner/name) cloned as the template. */ + repo: string; } export const TEMPLATES: TemplateEntry[] = [ - // Scraping - { - name: 'Scrape / Basic', - description: 'Simple scrape + crawl scripts', - path: 'scrape-basic', - }, - { - name: 'Scrape / Express', - description: 'Express server with scrape, crawl, and search endpoints', - path: 'scrape-express', - }, - { - name: 'Scrape / Next.js', - description: 'Next.js app with server actions for scraping', - path: 'scrape-nextjs', - }, - - // Browser - { - name: 'Browser / Basic', - description: 'Playwright and Puppeteer CDP scripts with Firecrawl browser', - path: 'browser-basic', - }, - { - name: 'Browser / Express', - description: 'Express server with browser automation endpoints', - path: 'browser-express', - }, - { - name: 'Browser / AI SDK', - description: - 'Next.js browser co-pilot with Vercel AI SDK and live session UI', - path: '_external:firecrawl/browser-ai-sdk', - }, - - // AI Frameworks - { - name: 'AI / Vercel AI SDK', - description: 'Firecrawl tools with Vercel AI SDK', - path: 'ai-vercel', - }, - { - name: 'AI / LangChain', - description: 'Firecrawl tools with LangChain agents', - path: 'ai-langchain', - }, - - // Full apps { name: 'Open Lovable', description: 'Clone and recreate any website as a modern React app', - path: '_external:firecrawl/open-lovable', + repo: 'firecrawl/open-lovable', }, ]; @@ -776,39 +727,18 @@ function copyTemplateFiles( } } -async function downloadFromRepo( - repo: string, - subdir: string | null -): Promise { +async function downloadFromRepo(repo: string): Promise { const fs = await import('fs'); const path = await import('path'); const { execSync: exec } = await import('child_process'); const targetDir = process.cwd(); const tmpDir = path.join(targetDir, `.firecrawl-template-${Date.now()}`); - // Try sparse checkout for subdirectory, full clone for whole repo try { - if (subdir) { - fs.mkdirSync(tmpDir, { recursive: true }); - exec( - `git clone --depth 1 --filter=blob:none --sparse https://github.com/${repo}.git "${tmpDir}"`, - { stdio: 'pipe' } - ); - exec(`git -C "${tmpDir}" sparse-checkout set "${subdir}"`, { - stdio: 'pipe', - }); - const srcDir = path.join(tmpDir, subdir); - if (!fs.existsSync(srcDir)) { - throw new Error(`Template directory "${subdir}" not found in ${repo}`); - } - copyTemplateFiles(srcDir, targetDir, fs, path); - } else { - exec(`git clone --depth 1 https://github.com/${repo}.git "${tmpDir}"`, { - stdio: 'pipe', - }); - copyTemplateFiles(tmpDir, targetDir, fs, path); - } - + exec(`git clone --depth 1 https://github.com/${repo}.git "${tmpDir}"`, { + stdio: 'pipe', + }); + copyTemplateFiles(tmpDir, targetDir, fs, path); fs.rmSync(tmpDir, { recursive: true, force: true }); return; } catch (gitError) { @@ -860,16 +790,7 @@ async function downloadFromRepo( `tar -xzf "${tmpTar}" -C "${extractDir}" --strip-components=1`, { stdio: 'pipe' } ); - - const srcDir = subdir - ? path.join(extractDir, subdir) - : extractDir; - if (!fs.existsSync(srcDir)) { - throw new Error( - `Template directory "${subdir}" not found in tarball` - ); - } - copyTemplateFiles(srcDir, targetDir, fs, path); + copyTemplateFiles(extractDir, targetDir, fs, path); fs.rmSync(tmpTar, { force: true }); fs.rmSync(extractDir, { recursive: true, force: true }); @@ -885,6 +806,28 @@ async function downloadFromRepo( }); } +/** Download a template into the current directory; returns false on failure. */ +async function downloadTemplate(template: TemplateEntry): Promise { + console.log(` ${dim}github.com/${template.repo}${reset}\n`); + try { + await downloadFromRepo(template.repo); + console.log(`\n ${green}✓${reset} Template ready\n`); + return true; + } catch (error) { + console.error(`\n ${bold}Could not download template.${reset}`); + console.error( + ` ${dim}${error instanceof Error ? error.message : 'Unknown error'}${reset}\n` + ); + console.log(` Clone it manually:\n`); + console.log(` git clone https://github.com/${template.repo}.git\n`); + return false; + } +} + +function templateKey(template: TemplateEntry): string { + return template.repo.split('/').pop() ?? ''; +} + async function stepTemplate(): Promise { const { select, confirm: confirmPrompt } = await import('@inquirer/prompts'); @@ -903,37 +846,15 @@ async function stepTemplate(): Promise { })), }); - const isExternal = template.path.startsWith('_external:'); - const repo = isExternal - ? template.path.replace('_external:', '') - : TEMPLATES_REPO; - const subdir = isExternal ? null : template.path; - console.log(`\n Downloading ${bold}${template.name}${reset}...`); - console.log( - ` ${dim}github.com/${repo}${subdir ? '/' + subdir : ''}${reset}\n` - ); - try { - await downloadFromRepo(repo, subdir); - console.log(`\n ${green}✓${reset} Template ready\n`); - } catch (error) { - console.error(`\n ${bold}Could not download template.${reset}`); - console.error( - ` ${dim}${error instanceof Error ? error.message : 'Unknown error'}${reset}\n` - ); - console.log(` Clone it manually:\n`); - console.log( - ` git clone https://github.com/${repo}.git${subdir ? ' && cp -r ' + repo.split('/')[1] + '/' + subdir + '/* .' : ''}\n` - ); - } + await downloadTemplate(template); } export function findTemplate(name: string): TemplateEntry | undefined { const lower = name.toLowerCase(); - return TEMPLATES.find((t) => { - const path = t.path.replace('_external:', '').split('/').pop() ?? ''; - return path === lower || t.name.toLowerCase() === lower; - }); + return TEMPLATES.find( + (t) => templateKey(t) === lower || t.name.toLowerCase() === lower + ); } export async function scaffoldTemplate(templatePath: string): Promise { @@ -942,40 +863,19 @@ export async function scaffoldTemplate(templatePath: string): Promise { console.error(`\n Unknown template: ${bold}${templatePath}${reset}\n`); console.log(` Available templates:\n`); for (const t of TEMPLATES) { - const key = t.path.replace('_external:', '').split('/').pop() ?? ''; - console.log(` ${bold}${key}${reset} ${dim}${t.description}${reset}`); + console.log( + ` ${bold}${templateKey(t)}${reset} ${dim}${t.description}${reset}` + ); } console.log(''); process.exit(1); } - const isExternal = template.path.startsWith('_external:'); - const repo = isExternal - ? template.path.replace('_external:', '') - : TEMPLATES_REPO; - const subdir = isExternal ? null : template.path; - console.log(''); console.log( ` ${orange}🔥 ${bold}firecrawl${reset} ${dim}${template.name}${reset}` ); - console.log( - ` ${dim}github.com/${repo}${subdir ? '/' + subdir : ''}${reset}\n` - ); - try { - await downloadFromRepo(repo, subdir); - console.log(`\n ${green}✓${reset} Template ready\n`); - } catch (error) { - console.error(`\n ${bold}Could not download template.${reset}`); - console.error( - ` ${dim}${error instanceof Error ? error.message : 'Unknown error'}${reset}\n` - ); - console.log(` Clone it manually:\n`); - console.log( - ` git clone https://github.com/${repo}.git${subdir ? ' && cp -r ' + repo.split('/')[1] + '/' + subdir + '/* .' : ''}\n` - ); - process.exit(1); - } + if (!(await downloadTemplate(template))) process.exit(1); } export async function handleInitCommand( diff --git a/src/commands/setup.ts b/src/commands/setup.ts index f8d27bf439..8dc4f9ada0 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -79,13 +79,16 @@ const dim = '\x1b[2m'; const reset = '\x1b[0m'; const ADD_MCP_PACKAGE = 'add-mcp@1.14.0'; const ENV_API_KEY = 'FIRECRAWL_API_KEY'; -const ADD_MCP_LAUNCH_AGENTS = [ - 'claude-code', - 'vscode', - 'codex', - 'opencode', - 'cursor', -] as const; +const ADD_MCP_LAUNCH_AGENTS: Record = { + 'claude-code': 'Claude Code', + vscode: 'VS Code', + codex: 'Codex', + opencode: 'OpenCode', + cursor: 'Cursor', + 'gemini-cli': 'Gemini CLI', + windsurf: 'Windsurf', + antigravity: 'Antigravity', +}; const SKILL_REPO_LABELS: Record = { 'firecrawl/cli': 'Core CLI skills', @@ -154,6 +157,17 @@ function resolveWindowsCommand( return command; } +function isCommandOnPath(command: string): boolean { + if (process.platform === 'win32') { + return resolveWindowsCommand(command, process.env) !== command; + } + return (process.env.PATH ?? '') + .split(path.delimiter) + .some( + (directory) => directory && existsSync(path.join(directory, command)) + ); +} + /** * Cross-platform, injection-safe replacement for `execFileSync`. * @@ -223,11 +237,13 @@ function assertSubprocessSafeCredential( function environmentHeaderForAgent(agent?: string): string | undefined { switch (agent) { case 'claude-code': + case 'gemini-cli': case 'hermes': case 'openclaw': return `Bearer \${${ENV_API_KEY}}`; case 'cursor': case 'vscode': + case 'windsurf': return `Bearer \${env:${ENV_API_KEY}}`; case 'opencode': return `Bearer {env:${ENV_API_KEY}}`; @@ -698,19 +714,85 @@ export async function installMcp( await installAddMcp(options, resolvedAgent, runtimeEnv); } +/** Why --agent all cannot configure an add-mcp client, or undefined when it can. */ +function addMcpSkipReason( + agent: string, + apiKey: string | undefined +): string | undefined { + if (!apiKey) return undefined; + if (agent === 'codex') { + return isCommandOnPath('codex') ? undefined : 'codex not found on PATH'; + } + if (environmentHeaderForAgent(agent)) return undefined; + return 'no verified environment-variable syntax for the API key, rerun with --keyless or configure it manually'; +} + +/** + * Configure every launch integration. A client that cannot be configured here + * is skipped and reported; only a failure in a client we attempted is an error. + */ async function installAllMcpLaunchers( options: SetupOptions, runtimeEnv: NodeJS.ProcessEnv ): Promise { - for (const agent of ADD_MCP_LAUNCH_AGENTS) { - await installAddMcp( - { ...options, yes: true }, - { kind: 'add-mcp', agent }, - runtimeEnv + const apiKey = options.keyless ? undefined : getApiKey(); + const clients = [ + ...Object.entries(ADD_MCP_LAUNCH_AGENTS).map(([agent, label]) => ({ + label, + skipReason: addMcpSkipReason(agent, apiKey), + install: () => + installAddMcp( + { ...options, yes: true }, + { kind: 'add-mcp', agent }, + runtimeEnv + ), + })), + { + label: 'Hermes Agent', + skipReason: undefined, + install: () => installHermesMcp(runtimeEnv, options.keyless), + }, + { + label: 'OpenClaw', + skipReason: isCommandOnPath('openclaw') + ? undefined + : 'openclaw not found on PATH', + install: () => installOpenClawMcp(runtimeEnv, options.keyless), + }, + ]; + + const configured: string[] = []; + const skipped: string[] = []; + const failed: string[] = []; + for (const client of clients) { + if (client.skipReason) { + console.log(`Skipping ${client.label}: ${client.skipReason}.\n`); + skipped.push(`${client.label} (${client.skipReason})`); + continue; + } + try { + await client.install(); + configured.push(client.label); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(`${client.label}: ${message}\n`); + failed.push(client.label); + } + } + + console.log('Firecrawl MCP setup summary:'); + console.log( + ` ${green}✓${reset} Configured: ${configured.join(', ') || 'none'}` + ); + if (skipped.length > 0) { + console.log(` ${dim}-${reset} Skipped: ${skipped.join('; ')}`); + } + if (failed.length > 0) { + console.log(` ✗ Failed: ${failed.join(', ')}`); + throw new Error( + `Failed to configure Firecrawl MCP for ${failed.join(', ')}.` ); } - await installHermesMcp(runtimeEnv, options.keyless); - await installOpenClawMcp(runtimeEnv, options.keyless); } async function installAddMcp( diff --git a/src/index.ts b/src/index.ts index 4db5d89928..33a5da1837 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2478,10 +2478,7 @@ program .description( 'Set up Firecrawl: install CLI, authenticate, add integrations, and scaffold a template' ) - .argument( - '[template]', - 'Template to scaffold (e.g. browser-nextjs, scrape-express)' - ) + .argument('[template]', 'Template to scaffold (e.g. open-lovable)') .option( '--all', 'Explicitly install skills to all detected agents (default unless --agent is used)' From cb9a041958d55a937247cb0e09a42e8d72726c54 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:43:12 +0000 Subject: [PATCH 02/16] fix(setup): require an executable for client detection; tighten template and Windows tests --- README.md | 4 +++- src/__tests__/commands/init.test.ts | 7 +++---- src/__tests__/commands/setup.test.ts | 3 ++- src/commands/setup.ts | 18 +++++++++++++----- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a860d205a5..e6fd780c92 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,9 @@ Claude Code, VS Code, Codex, OpenCode, Cursor, Gemini CLI, Windsurf, Antigravity, Hermes Agent, and OpenClaw in one run. Clients that cannot be configured on this machine (for example OpenClaw when the `openclaw` command is not installed) are skipped and listed in the summary; the command only fails -when a client it tried to configure fails. +when a client it tried to configure fails. When `FIRECRAWL_API_KEY` is set, +Antigravity is skipped because it has no verified way to reference the key from +the environment; add `--keyless` to include it. To make Firecrawl the default web provider for supported AI agents: diff --git a/src/__tests__/commands/init.test.ts b/src/__tests__/commands/init.test.ts index f8af520c35..d5f990ef8e 100644 --- a/src/__tests__/commands/init.test.ts +++ b/src/__tests__/commands/init.test.ts @@ -319,10 +319,9 @@ describe('init templates', () => { }); it('only lists templates that are standalone public repositories', () => { - expect(TEMPLATES.length).toBeGreaterThan(0); - for (const template of TEMPLATES) { - expect(template.repo).toMatch(/^firecrawl\/[\w.-]+$/); - } + expect(TEMPLATES.map((template) => template.repo)).toEqual([ + 'firecrawl/open-lovable', + ]); expect(findTemplate('open-lovable')?.repo).toBe('firecrawl/open-lovable'); }); diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 53b7e91dc4..b688ba4608 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -35,7 +35,8 @@ function stubCommands(root: string, commands: string[]): string { const bin = path.join(root, 'bin'); mkdirSync(bin, { recursive: true }); for (const command of commands) { - writeFileSync(path.join(bin, command), '', { mode: 0o755 }); + const filename = process.platform === 'win32' ? `${command}.cmd` : command; + writeFileSync(path.join(bin, filename), '', { mode: 0o755 }); } return bin; } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 8dc4f9ada0..ff1f35ca27 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -5,10 +5,13 @@ import { execFileSync } from 'child_process'; import { + accessSync, chmodSync, + constants as fsConstants, existsSync, mkdirSync, readFileSync, + statSync, writeFileSync, } from 'fs'; import os from 'os'; @@ -161,11 +164,16 @@ function isCommandOnPath(command: string): boolean { if (process.platform === 'win32') { return resolveWindowsCommand(command, process.env) !== command; } - return (process.env.PATH ?? '') - .split(path.delimiter) - .some( - (directory) => directory && existsSync(path.join(directory, command)) - ); + return (process.env.PATH ?? '').split(path.delimiter).some((directory) => { + if (!directory) return false; + const candidate = path.join(directory, command); + try { + accessSync(candidate, fsConstants.X_OK); + return statSync(candidate).isFile(); + } catch { + return false; + } + }); } /** From 8b4e2b77241fcbd4b4af8e45fc205f4d9e50f888 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:03:56 +0000 Subject: [PATCH 03/16] feat(cli): let wrappers set the integration tag with FIRECRAWL_INTEGRATION --- README.md | 6 ++++ package.json | 2 +- src/__tests__/commands/developer.test.ts | 15 ++++++++++ src/__tests__/commands/scrape.test.ts | 38 ++++++++++++++++++++++++ src/__tests__/utils/integration.test.ts | 35 ++++++++++++++++++++++ src/commands/agent.ts | 3 +- src/commands/alexandria.ts | 3 +- src/commands/browser.ts | 5 ++-- src/commands/crawl.ts | 3 +- src/commands/developer.ts | 3 +- src/commands/feedback.ts | 3 +- src/commands/gov.ts | 3 +- src/commands/interact.ts | 6 +++- src/commands/map.ts | 3 +- src/commands/parse.ts | 3 +- src/commands/research.ts | 3 +- src/commands/scrape.ts | 5 ++-- src/commands/search-feedback.ts | 3 +- src/commands/search.ts | 5 ++-- src/utils/integration.ts | 9 ++++++ vitest.config.mjs | 1 + 21 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 src/__tests__/utils/integration.test.ts create mode 100644 src/utils/integration.ts diff --git a/README.md b/README.md index 33d0e88639..8afbc9f991 100644 --- a/README.md +++ b/README.md @@ -1023,6 +1023,12 @@ To disable telemetry, set the environment variable: export FIRECRAWL_NO_TELEMETRY=1 ``` +API requests are tagged with `integration: cli`. A tool that wraps the CLI can tag its own traffic by setting `FIRECRAWL_INTEGRATION` to a value that starts with an underscore; other values are ignored and the tag stays `cli`: + +```bash +export FIRECRAWL_INTEGRATION=_my-tool +``` + --- ## Experimental diff --git a/package.json b/package.json index 72717feef2..bbdbceeac8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.26.10", + "version": "1.26.11", "publishConfig": { "tag": "latest" }, diff --git a/src/__tests__/commands/developer.test.ts b/src/__tests__/commands/developer.test.ts index b41dbb1d4e..a28107a71a 100644 --- a/src/__tests__/commands/developer.test.ts +++ b/src/__tests__/commands/developer.test.ts @@ -84,6 +84,21 @@ describe('handleDeveloperSearchCommand', () => { ); }); + it('tags the query with FIRECRAWL_INTEGRATION when set', async () => { + vi.stubEnv('FIRECRAWL_INTEGRATION', '_rtk'); + mockHttpGet.mockResolvedValue(mockDeveloperResponse([sampleResult])); + + try { + await handleDeveloperSearchCommand({ query: 'tokio spawn_blocking' }); + } finally { + vi.unstubAllEnvs(); + } + + expect(mockHttpGet).toHaveBeenCalledWith( + '/v2/search/developer?query=tokio+spawn_blocking&integration=_rtk' + ); + }); + it('passes apiUrl and apiKey to getClient when provided', async () => { mockHttpGet.mockResolvedValue(mockDeveloperResponse([])); diff --git a/src/__tests__/commands/scrape.test.ts b/src/__tests__/commands/scrape.test.ts index b9c9606882..46bb93eb32 100644 --- a/src/__tests__/commands/scrape.test.ts +++ b/src/__tests__/commands/scrape.test.ts @@ -94,6 +94,44 @@ describe('executeScrape', () => { expect(mockClient.scrape).not.toHaveBeenCalled(); }); + it('sends the FIRECRAWL_INTEGRATION tag instead of cli', async () => { + vi.stubEnv('FIRECRAWL_INTEGRATION', '_rtk'); + mockClient.scrape.mockResolvedValue({ markdown: '# Test Content' }); + vi.mocked(isKeylessMode).mockReturnValueOnce(true); + vi.mocked(keylessRequest).mockResolvedValueOnce({ + data: { markdown: '# Test Content' }, + }); + try { + await executeScrape({ url: 'https://example.com' }); + await executeScrape({ url: 'https://example.com' }); + } finally { + vi.unstubAllEnvs(); + } + expect(keylessRequest).toHaveBeenCalledWith('/v2/scrape', { + url: 'https://example.com', + formats: ['markdown'], + integration: '_rtk', + }); + expect(mockClient.scrape).toHaveBeenCalledWith('https://example.com', { + formats: ['markdown'], + integration: '_rtk', + }); + }); + + it('ignores a FIRECRAWL_INTEGRATION tag without a leading underscore', async () => { + vi.stubEnv('FIRECRAWL_INTEGRATION', 'rtk'); + mockClient.scrape.mockResolvedValue({ markdown: '# Test Content' }); + try { + await executeScrape({ url: 'https://example.com' }); + } finally { + vi.unstubAllEnvs(); + } + expect(mockClient.scrape).toHaveBeenCalledWith('https://example.com', { + formats: ['markdown'], + integration: 'cli', + }); + }); + it('should pass apiUrl to getClient when provided', async () => { const mockResponse = { markdown: '# Test Content' }; mockClient.scrape.mockResolvedValue(mockResponse); diff --git a/src/__tests__/utils/integration.test.ts b/src/__tests__/utils/integration.test.ts new file mode 100644 index 0000000000..48b09f64a8 --- /dev/null +++ b/src/__tests__/utils/integration.test.ts @@ -0,0 +1,35 @@ +/** + * Tests for the integration tag + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getIntegration } from '../../utils/integration'; + +describe('getIntegration', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('defaults to cli', () => { + vi.stubEnv('FIRECRAWL_INTEGRATION', undefined); + expect(getIntegration()).toBe('cli'); + }); + + it('uses FIRECRAWL_INTEGRATION when it starts with an underscore', () => { + vi.stubEnv('FIRECRAWL_INTEGRATION', '_rtk'); + expect(getIntegration()).toBe('_rtk'); + }); + + it('trims surrounding whitespace', () => { + vi.stubEnv('FIRECRAWL_INTEGRATION', ' _rtk\n'); + expect(getIntegration()).toBe('_rtk'); + }); + + it.each(['', ' ', 'rtk', 'zapier', 'cli_', ' x_rtk'])( + 'ignores %j and falls back to cli', + (value) => { + vi.stubEnv('FIRECRAWL_INTEGRATION', value); + expect(getIntegration()).toBe('cli'); + } + ); +}); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 23603ad204..bf8e798e66 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -27,6 +27,7 @@ import { writeOutput } from '../utils/output'; import { createSpinner } from '../utils/spinner'; import { TERMS_DASHBOARD_URL } from './terms'; import { readFileSync } from 'fs'; +import { getIntegration } from '../utils/integration'; /** * Extract detailed error message from API errors @@ -309,7 +310,7 @@ export async function executeAgent( integration?: string; } = { prompt, - integration: 'cli', + integration: getIntegration(), }; if (options.urls && options.urls.length > 0) { diff --git a/src/commands/alexandria.ts b/src/commands/alexandria.ts index 2949e400c7..9703384b8f 100644 --- a/src/commands/alexandria.ts +++ b/src/commands/alexandria.ts @@ -7,6 +7,7 @@ import { getApiKey } from '../utils/config'; import { writeOutput } from '../utils/output'; import { withoutResultPrices } from '../utils/alexandria-prices'; import { TERMS_DASHBOARD_URL } from './terms'; +import { getIntegration } from '../utils/integration'; type Call = AlexandriaCall & { options: Record }; export type AlexandriaOptions = { @@ -213,7 +214,7 @@ export async function requestAlexandria( const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); const result = await app.scrape({ alexandria: calls, - integration: 'cli', + integration: getIntegration(), timeout: options.timeout, requestId, }); diff --git a/src/commands/browser.ts b/src/commands/browser.ts index c433cdeec1..cff499f216 100644 --- a/src/commands/browser.ts +++ b/src/commands/browser.ts @@ -12,6 +12,7 @@ import { getSessionId, } from '../utils/browser-session'; import { writeOutput } from '../utils/output'; +import { getIntegration } from '../utils/integration'; export interface BrowserLaunchOptions { ttl?: number; @@ -77,7 +78,7 @@ export async function handleBrowserLaunch( saveChanges?: boolean; }; integration?: string; - } = { integration: 'cli' }; + } = { integration: getIntegration() }; if (options.ttl !== undefined) args.ttl = options.ttl; if (options.ttlInactivity !== undefined) args.activityTtl = options.ttlInactivity; @@ -297,7 +298,7 @@ export async function handleBrowserQuickExecute( saveChanges?: boolean; }; integration?: string; - } = { integration: 'cli' }; + } = { integration: getIntegration() }; if (options.profile) { launchArgs.profile = { name: options.profile, diff --git a/src/commands/crawl.ts b/src/commands/crawl.ts index 0242956363..0c16e3df4b 100644 --- a/src/commands/crawl.ts +++ b/src/commands/crawl.ts @@ -10,6 +10,7 @@ import type { import { getClient } from '../utils/client'; import { isJobId } from '../utils/job'; import { writeOutput } from '../utils/output'; +import { getIntegration } from '../utils/integration'; /** * Execute crawl status check @@ -85,7 +86,7 @@ export async function executeCrawl( // Build crawl options const crawlOptions: any = { - integration: 'cli', + integration: getIntegration(), }; if (options.limit !== undefined) { diff --git a/src/commands/developer.ts b/src/commands/developer.ts index 5d2f525e39..35e7183234 100644 --- a/src/commands/developer.ts +++ b/src/commands/developer.ts @@ -8,6 +8,7 @@ import type { DeveloperSearchResponse, DeveloperSourceStatus, } from '../types/developer'; +import { getIntegration } from '../utils/integration'; // The other mount, /v2/developer/search, rejects keyless callers and may be // withdrawn. @@ -17,7 +18,7 @@ async function getDeveloper( path: string, options: DeveloperSearchOptions ): Promise { - const url = `${path}${path.includes('?') ? '&' : '?'}integration=cli`; + const url = `${path}${path.includes('?') ? '&' : '?'}integration=${encodeURIComponent(getIntegration())}`; if (isKeylessMode(options.apiKey, options.apiUrl)) { return (await keylessGet(url)) as T; diff --git a/src/commands/feedback.ts b/src/commands/feedback.ts index 2dea18a61c..a8efdf161d 100644 --- a/src/commands/feedback.ts +++ b/src/commands/feedback.ts @@ -9,6 +9,7 @@ import { type SearchFeedbackRating, type ValuableSourceInput, } from './search-feedback'; +import { getIntegration } from '../utils/integration'; export type EndpointFeedbackEndpoint = 'search' | 'scrape' | 'parse' | 'map'; @@ -259,7 +260,7 @@ export async function executeEndpointFeedback( ...(options.endpoint === 'alexandria' ? {} : { jobId: options.jobId }), rating: options.rating, origin: 'cli', - integration: 'cli', + integration: getIntegration(), }; if (options.endpoint !== 'alexandria' && !options.jobId) { diff --git a/src/commands/gov.ts b/src/commands/gov.ts index f92f679c2e..ca0d677281 100644 --- a/src/commands/gov.ts +++ b/src/commands/gov.ts @@ -5,11 +5,12 @@ import type { GovSearchOptions, GovSearchResponse, } from '../types/gov'; +import { getIntegration } from '../utils/integration'; const BASE = '/v2/search/gov'; async function getGov(path: string, options: GovSearchOptions): Promise { - const url = `${path}${path.includes('?') ? '&' : '?'}integration=cli`; + const url = `${path}${path.includes('?') ? '&' : '?'}integration=${encodeURIComponent(getIntegration())}`; if (isKeylessMode(options.apiKey, options.apiUrl)) { return (await keylessGet(url)) as T; diff --git a/src/commands/interact.ts b/src/commands/interact.ts index 6a99b36dbe..8fb550e270 100644 --- a/src/commands/interact.ts +++ b/src/commands/interact.ts @@ -11,6 +11,7 @@ import { clearInteractSession, } from '../utils/interact-session'; import { writeOutput } from '../utils/output'; +import { getIntegration } from '../utils/integration'; export interface InteractExecuteOptions { scrapeId?: string; @@ -80,7 +81,10 @@ export async function handleInteractExecute( `Using scrape ${scrapeId}` + (storedUrl ? ` (${storedUrl})` : '') + '\n' ); - const body: Record = { origin: 'cli', integration: 'cli' }; + const body: Record = { + origin: 'cli', + integration: getIntegration(), + }; if (options.code) { body.code = options.code; diff --git a/src/commands/map.ts b/src/commands/map.ts index bbac27be84..4692f35d6a 100644 --- a/src/commands/map.ts +++ b/src/commands/map.ts @@ -5,6 +5,7 @@ import type { MapOptions, MapResult } from '../types/map'; import { getClient } from '../utils/client'; import { writeOutput } from '../utils/output'; +import { getIntegration } from '../utils/integration'; /** * Execute map command @@ -16,7 +17,7 @@ export async function executeMap(options: MapOptions): Promise { // Build map options const mapOptions: any = { - integration: 'cli', + integration: getIntegration(), }; if (options.limit !== undefined) { diff --git a/src/commands/parse.ts b/src/commands/parse.ts index 7e7e7ab8ba..8645c8215c 100644 --- a/src/commands/parse.ts +++ b/src/commands/parse.ts @@ -14,6 +14,7 @@ import type { ScrapeFormat } from '../types/scrape'; import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; import { handleScrapeOutput } from '../utils/output'; +import { getIntegration } from '../utils/integration'; const DEFAULT_API_URL = 'https://api.firecrawl.dev'; @@ -95,7 +96,7 @@ function buildFormats(options: ParseOptions): FormatOption[] { function buildOptionsPayload(options: ParseOptions): Record { const payload: Record = { formats: buildFormats(options), - integration: 'cli', + integration: getIntegration(), }; if (options.onlyMainContent !== undefined) { diff --git a/src/commands/research.ts b/src/commands/research.ts index 45a03ddc4b..784d73f157 100644 --- a/src/commands/research.ts +++ b/src/commands/research.ts @@ -10,6 +10,7 @@ import type { SearchGitHubOptions, SearchPapersOptions, } from '../types/research'; +import { getIntegration } from '../utils/integration'; const BASE = '/v2/search/research'; const MAX_AUTHORS = 15; @@ -44,7 +45,7 @@ async function getResearch( path: string, options: ResearchBaseOptions ): Promise { - const url = `${path}${path.includes('?') ? '&' : '?'}integration=cli`; + const url = `${path}${path.includes('?') ? '&' : '?'}integration=${encodeURIComponent(getIntegration())}`; if (isKeylessMode(options.apiKey, options.apiUrl)) { return (await keylessGet(url)) as T; diff --git a/src/commands/scrape.ts b/src/commands/scrape.ts index 96fd28078d..f1c36709d6 100644 --- a/src/commands/scrape.ts +++ b/src/commands/scrape.ts @@ -20,6 +20,7 @@ import { executeMap } from './map'; import { getStatus } from './status'; import { requireAlexandriaKey } from './alexandria'; import { withoutToolPrice } from '../utils/alexandria-prices'; +import { getIntegration } from '../utils/integration'; /** * Output timing information if requested @@ -93,7 +94,7 @@ export async function executeScrape( const scrapeParams: Record = { formats: resolvedFormats, - integration: 'cli', + integration: getIntegration(), }; if (options.onlyMainContent !== undefined) { @@ -157,7 +158,7 @@ export async function executeScrape( let result: any; if (isKeylessMode(options.apiKey, options.apiUrl)) { // Keyless free tier: header-less request. The API identifies the CLI via - // the `integration: 'cli'` field already in scrapeParams. + // the `integration` field already in scrapeParams. const json = await keylessRequest('/v2/scrape', { url: options.url, ...scrapeParams, diff --git a/src/commands/search-feedback.ts b/src/commands/search-feedback.ts index 35898e78b4..971685a6e3 100644 --- a/src/commands/search-feedback.ts +++ b/src/commands/search-feedback.ts @@ -1,5 +1,6 @@ import { getConfig, validateConfig } from '../utils/config'; import { getClient } from '../utils/client'; +import { getIntegration } from '../utils/integration'; export type SearchFeedbackRating = 'good' | 'bad' | 'partial'; @@ -107,7 +108,7 @@ export async function executeSearchFeedback( const body: Record = { rating: options.rating, origin: 'cli', - integration: 'cli', + integration: getIntegration(), }; if (options.valuableSources && options.valuableSources.length > 0) { diff --git a/src/commands/search.ts b/src/commands/search.ts index 39e8449a7e..70dcc002e2 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -18,6 +18,7 @@ import { getApiKey } from '../utils/config'; import { writeOutput } from '../utils/output'; import { withoutToolPrice } from '../utils/alexandria-prices'; import { apiFailure, requireAlexandriaKey } from './alexandria'; +import { getIntegration } from '../utils/integration'; const DEFAULT_SEARCH_LIMIT = 5; @@ -39,7 +40,7 @@ export async function executeSearch( // Build search options for the SDK const searchParams: Record = { limit: options.limit ?? DEFAULT_SEARCH_LIMIT, - integration: 'cli', + integration: getIntegration(), }; searchParams.toolDetail = options.toolDetail ?? 'compact'; @@ -128,7 +129,7 @@ export async function executeSearch( let envelope: Record; if (isKeylessMode(options.apiKey, options.apiUrl)) { // Keyless free tier: header-less request. The API identifies the CLI via - // the `integration: 'cli'` field already in searchParams. + // the `integration` field already in searchParams. envelope = (await keylessRequest('/v2/search', searchBody)) as Record< string, any diff --git a/src/utils/integration.ts b/src/utils/integration.ts new file mode 100644 index 0000000000..ee8bc71ff4 --- /dev/null +++ b/src/utils/integration.ts @@ -0,0 +1,9 @@ +/** + * The `integration` tag sent with API requests. A tool that wraps the CLI can + * set `FIRECRAWL_INTEGRATION` to a value starting with `_`; anything else + * falls back to `cli`. + */ +export function getIntegration(): string { + const tag = process.env.FIRECRAWL_INTEGRATION?.trim(); + return tag?.startsWith('_') ? tag : 'cli'; +} diff --git a/vitest.config.mjs b/vitest.config.mjs index d8ea2abfd3..6bb69ea622 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -5,6 +5,7 @@ export default defineConfig({ globals: true, environment: 'node', include: ['src/**/*.test.ts'], + env: { FIRECRAWL_INTEGRATION: '' }, coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], From f69759c43aadf0af270f1b40c5ee9dc79019f496 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:08:36 +0000 Subject: [PATCH 04/16] test: cover the integration tag in feedback requests and narrow the README --- README.md | 2 +- src/__tests__/commands/feedback.test.ts | 37 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8afbc9f991..014427dcc7 100644 --- a/README.md +++ b/README.md @@ -1023,7 +1023,7 @@ To disable telemetry, set the environment variable: export FIRECRAWL_NO_TELEMETRY=1 ``` -API requests are tagged with `integration: cli`. A tool that wraps the CLI can tag its own traffic by setting `FIRECRAWL_INTEGRATION` to a value that starts with an underscore; other values are ignored and the tag stays `cli`: +Requests to endpoints that accept an integration tag (scrape, search, map, crawl, agent, parse, interact, browser, feedback and the research, developer and gov searches) send `integration: cli`. A tool that wraps the CLI can tag those requests as its own by setting `FIRECRAWL_INTEGRATION` to a value that starts with an underscore (surrounding whitespace is ignored); other values are ignored and the tag stays `cli`: ```bash export FIRECRAWL_INTEGRATION=_my-tool diff --git a/src/__tests__/commands/feedback.test.ts b/src/__tests__/commands/feedback.test.ts index 6996972d42..c392758816 100644 --- a/src/__tests__/commands/feedback.test.ts +++ b/src/__tests__/commands/feedback.test.ts @@ -7,6 +7,7 @@ import { parsePageNumbersArg, } from '../../commands/feedback'; import { parseAlexandriaFeedbackArray } from '../../commands/alexandria-feedback'; +import { executeSearchFeedback } from '../../commands/search-feedback'; import { getClient } from '../../utils/client'; import { initializeConfig } from '../../utils/config'; import { setupTest, teardownTest } from '../utils/mock-client'; @@ -155,6 +156,42 @@ describe('executeEndpointFeedback', () => { }); }); + it('sends the FIRECRAWL_INTEGRATION tag in endpoint and search feedback', async () => { + vi.stubEnv('FIRECRAWL_INTEGRATION', '_rtk'); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, feedbackId: 'feedback-1' }), + }); + + try { + await executeEndpointFeedback({ + endpoint: 'scrape', + jobId: '0193f6c5-1234-7890-abcd-1234567890ab', + rating: 'good', + }); + await executeSearchFeedback({ searchId: 'search-1', rating: 'good' }); + } finally { + vi.unstubAllEnvs(); + } + + const [endpointUrl, endpointRequest] = mockFetch.mock.calls[0]; + expect(endpointUrl).toBe('https://api.firecrawl.dev/v2/feedback'); + expect(JSON.parse(endpointRequest.body)).toMatchObject({ + origin: 'cli', + integration: '_rtk', + }); + const [searchUrl, searchRequest] = mockFetch.mock.calls[1]; + expect(searchUrl).toBe( + 'https://api.firecrawl.dev/v2/search/search-1/feedback' + ); + expect(JSON.parse(searchRequest.body)).toEqual({ + rating: 'good', + origin: 'cli', + integration: '_rtk', + }); + }); + it('treats team opt-out as a disabled success', async () => { mockFetch.mockResolvedValue({ ok: false, From 77c4fa4cba9d4cf1f07079598788691c2ca64919 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:14:11 +0000 Subject: [PATCH 05/16] chore(deps): bump firecrawl SDK to 4.49.1 --- package.json | 4 ++-- pnpm-lock.yaml | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 72717feef2..ff61b2d340 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.26.10", + "version": "1.26.11", "publishConfig": { "tag": "latest" }, @@ -85,7 +85,7 @@ "dependencies": { "@inquirer/prompts": "^8.2.1", "commander": "^14.0.2", - "firecrawl": "4.44.0", + "firecrawl": "4.49.1", "yaml": "^2.9.0", "zod-to-json-schema": "3.24.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d3c93ba5a..a621b3ecdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^14.0.2 version: 14.0.2 firecrawl: - specifier: 4.44.0 - version: 4.44.0 + specifier: 4.49.1 + version: 4.49.1 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -667,8 +667,8 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - firecrawl@4.44.0: - resolution: {integrity: sha512-cpjl8pqYUYYCTtl0cXAoLgWCYotIvjKDrh31nx/HdB/1OcrfNswXRrbrnDCAkM1Ij1BUrd22mUHkBboIGiv3cg==} + firecrawl@4.49.1: + resolution: {integrity: sha512-ssm5wDo3xwiPyb7uPMu9sfSx57VOztW2SgP6cmOgswxARrWuHUSHL5InF/rWhddRtgdhEXVhJlA+bk9mNOUMOQ==} engines: {node: '>=22.0.0'} follow-redirects@1.16.0: @@ -1569,7 +1569,7 @@ snapshots: dependencies: to-regex-range: 5.0.1 - firecrawl@4.44.0: + firecrawl@4.49.1: dependencies: axios: 1.20.0 typescript-event-target: 1.1.2 From f26abb7e51d2fca677614750b9a15637a57caf95 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:44:21 +0000 Subject: [PATCH 06/16] chore: bump version to 1.26.11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 72717feef2..bbdbceeac8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.26.10", + "version": "1.26.11", "publishConfig": { "tag": "latest" }, From 0c4e55a3fc85a566474916e135a37199b7d07279 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:04:35 +0000 Subject: [PATCH 07/16] chore: bump version to 1.26.12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ff61b2d340..964e76a5c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.26.11", + "version": "1.26.12", "publishConfig": { "tag": "latest" }, From e34d00fa134c93ca6dccdbe7abbb09f487a65491 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:36:12 +0000 Subject: [PATCH 08/16] fix(setup): keep quiet setup mcp output compact; name the failing-client test accurately --- src/__tests__/commands/setup.test.ts | 2 +- src/commands/setup.ts | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 5949d03378..3c7ac1d370 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -715,7 +715,7 @@ describe('handleSetupCommand', () => { } }); - it('keeps configuring other clients and fails when an installed client fails', async () => { + it('keeps configuring other clients when one add-mcp client fails', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-fail-')); const originalPath = process.env.PATH; process.env.HOME = home; diff --git a/src/commands/setup.ts b/src/commands/setup.ts index ff1f35ca27..02a7629f6a 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -774,7 +774,9 @@ async function installAllMcpLaunchers( const failed: string[] = []; for (const client of clients) { if (client.skipReason) { - console.log(`Skipping ${client.label}: ${client.skipReason}.\n`); + if (!options.quiet) { + console.log(`Skipping ${client.label}: ${client.skipReason}.\n`); + } skipped.push(`${client.label} (${client.skipReason})`); continue; } @@ -788,15 +790,17 @@ async function installAllMcpLaunchers( } } - console.log('Firecrawl MCP setup summary:'); - console.log( - ` ${green}✓${reset} Configured: ${configured.join(', ') || 'none'}` - ); - if (skipped.length > 0) { - console.log(` ${dim}-${reset} Skipped: ${skipped.join('; ')}`); + if (!options.quiet) { + console.log('Firecrawl MCP setup summary:'); + console.log( + ` ${green}✓${reset} Configured: ${configured.join(', ') || 'none'}` + ); + if (skipped.length > 0) { + console.log(` ${dim}-${reset} Skipped: ${skipped.join('; ')}`); + } + if (failed.length > 0) console.log(` ✗ Failed: ${failed.join(', ')}`); } if (failed.length > 0) { - console.log(` ✗ Failed: ${failed.join(', ')}`); throw new Error( `Failed to configure Firecrawl MCP for ${failed.join(', ')}.` ); From 3c12476e71fa9e78882d2ec309c07109e19716e4 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:53:52 +0000 Subject: [PATCH 09/16] fix(setup): treat empty PATH entries as the current directory; document the stored-key prerequisite; restore an absent PATH in tests --- README.md | 3 +++ src/__tests__/commands/setup.test.ts | 12 ++++++++---- src/commands/setup.ts | 3 +-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e6fd780c92..e8498360d9 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ To install the Firecrawl MCP server into your editors (Cursor, Claude Code, VS C firecrawl setup mcp ``` +With a key saved by `firecrawl login`, export `FIRECRAWL_API_KEY` or add +`--keyless`: setup never writes a stored key into client configs. + Use `--agent ` for one client, or `--agent all --global` to configure Claude Code, VS Code, Codex, OpenCode, Cursor, Gemini CLI, Windsurf, Antigravity, Hermes Agent, and OpenClaw in one run. Clients that cannot be diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 3c7ac1d370..c6ecb4f022 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -644,7 +644,8 @@ describe('handleSetupCommand', () => { readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') ).toContain('Authorization: Bearer ${FIRECRAWL_API_KEY}'); } finally { - process.env.PATH = originalPath; + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; rmSync(home, { recursive: true, force: true }); } }); @@ -675,7 +676,8 @@ describe('handleSetupCommand', () => { 'Skipped: OpenClaw (openclaw not found on PATH)' ); } finally { - process.env.PATH = originalPath; + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; rmSync(home, { recursive: true, force: true }); } }); @@ -710,7 +712,8 @@ describe('handleSetupCommand', () => { 'Skipping Antigravity: no verified environment-variable syntax' ); } finally { - process.env.PATH = originalPath; + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; rmSync(home, { recursive: true, force: true }); } }); @@ -741,7 +744,8 @@ describe('handleSetupCommand', () => { expect(existsSync(path.join(home, '.hermes', 'config.yaml'))).toBe(true); } finally { vi.mocked(execFileSync).mockReset(); - process.env.PATH = originalPath; + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; rmSync(home, { recursive: true, force: true }); } }); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 02a7629f6a..02a077940e 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -165,8 +165,7 @@ function isCommandOnPath(command: string): boolean { return resolveWindowsCommand(command, process.env) !== command; } return (process.env.PATH ?? '').split(path.delimiter).some((directory) => { - if (!directory) return false; - const candidate = path.join(directory, command); + const candidate = path.join(directory || '.', command); try { accessSync(candidate, fsConstants.X_OK); return statSync(candidate).isFile(); From ffdc43943ee66780976b0b4914bc41df414e6562 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:00:50 +0000 Subject: [PATCH 10/16] fix(setup): ignore an unset PATH in client detection; name --agent in the exported-key note --- README.md | 5 +++-- src/commands/setup.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e8498360d9..9319f60609 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,9 @@ To install the Firecrawl MCP server into your editors (Cursor, Claude Code, VS C firecrawl setup mcp ``` -With a key saved by `firecrawl login`, export `FIRECRAWL_API_KEY` or add -`--keyless`: setup never writes a stored key into client configs. +With a key saved by `firecrawl login`, export `FIRECRAWL_API_KEY` and pass +`--agent ` (or `--agent all`), or add `--keyless`: setup never writes a +stored key into client configs. Use `--agent ` for one client, or `--agent all --global` to configure Claude Code, VS Code, Codex, OpenCode, Cursor, Gemini CLI, Windsurf, diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 02a077940e..c46942a69d 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -164,7 +164,8 @@ function isCommandOnPath(command: string): boolean { if (process.platform === 'win32') { return resolveWindowsCommand(command, process.env) !== command; } - return (process.env.PATH ?? '').split(path.delimiter).some((directory) => { + if (!process.env.PATH) return false; + return process.env.PATH.split(path.delimiter).some((directory) => { const candidate = path.join(directory || '.', command); try { accessSync(candidate, fsConstants.X_OK); From be477ff0691ff2f7d0d18b9f781c1aa9af09c163 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:17:39 +0000 Subject: [PATCH 11/16] fix(setup): normalize Cursor, Gemini CLI, Windsurf and Antigravity agent names; make the all-clients test portable to Windows --- src/__tests__/commands/setup.test.ts | 11 ++++++++--- src/commands/setup.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index c6ecb4f022..0222eb4133 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -738,9 +738,14 @@ describe('handleSetupCommand', () => { handleSetupCommand('mcp', { agent: 'all', global: true, yes: true }) ).rejects.toThrow('Failed to configure Firecrawl MCP for Cursor.'); - const commands = vi.mocked(execFileSync).mock.calls.map(([cmd]) => cmd); - expect(commands.filter((cmd) => cmd === 'npx')).toHaveLength(8); - expect(commands).toContain('openclaw'); + // On win32, .cmd clients run through cmd.exe with the client in its args. + const invoked = vi + .mocked(execFileSync) + .mock.calls.map(([cmd, args]) => + [cmd, ...((args as string[] | undefined) ?? [])].join(' ') + ); + expect(invoked.filter((line) => /\bnpx\b/.test(line))).toHaveLength(8); + expect(invoked.some((line) => /\bopenclaw\b/.test(line))).toBe(true); expect(existsSync(path.join(home, '.hermes', 'config.yaml'))).toBe(true); } finally { vi.mocked(execFileSync).mockReset(); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index c46942a69d..e220ee2984 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -303,6 +303,15 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { case 'opencode': case 'open-code': return { kind: 'add-mcp', agent: 'opencode' }; + case 'cursor': + return { kind: 'add-mcp', agent: 'cursor' }; + case 'gemini': + case 'gemini-cli': + return { kind: 'add-mcp', agent: 'gemini-cli' }; + case 'windsurf': + return { kind: 'add-mcp', agent: 'windsurf' }; + case 'antigravity': + return { kind: 'add-mcp', agent: 'antigravity' }; case 'hermes': case 'hermes-agent': return { kind: 'hermes' }; From 242752bf7f860d2799d24740fd7b2185e3649ab5 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:30:30 +0000 Subject: [PATCH 12/16] chore(deps): bump firecrawl SDK to 4.50.0 --- package.json | 2 +- pnpm-lock.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 964e76a5c2..a572279f43 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "dependencies": { "@inquirer/prompts": "^8.2.1", "commander": "^14.0.2", - "firecrawl": "4.49.1", + "firecrawl": "4.50.0", "yaml": "^2.9.0", "zod-to-json-schema": "3.24.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a621b3ecdf..94ba401942 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^14.0.2 version: 14.0.2 firecrawl: - specifier: 4.49.1 - version: 4.49.1 + specifier: 4.50.0 + version: 4.50.0 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -667,9 +667,9 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - firecrawl@4.49.1: - resolution: {integrity: sha512-ssm5wDo3xwiPyb7uPMu9sfSx57VOztW2SgP6cmOgswxARrWuHUSHL5InF/rWhddRtgdhEXVhJlA+bk9mNOUMOQ==} - engines: {node: '>=22.0.0'} + firecrawl@4.50.0: + resolution: {integrity: sha512-8XKMYKjofhBjdii/0DZON7J9ISVVDAdJ3z2g/GdRhseWMANT5ELMI4Ov4WKs6bTrAH3n5+n0vRo+xd2evf0OcQ==} + engines: {node: '>=18.0.0'} follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} @@ -1569,7 +1569,7 @@ snapshots: dependencies: to-regex-range: 5.0.1 - firecrawl@4.49.1: + firecrawl@4.50.0: dependencies: axios: 1.20.0 typescript-event-target: 1.1.2 From 2c2447e63ad2e318224af7014488157b193bc8af Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:47:27 +0000 Subject: [PATCH 13/16] docs(readme): list search-feedback among the integration-tagged requests --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5e882bf0fb..f30af81629 100644 --- a/README.md +++ b/README.md @@ -1036,7 +1036,7 @@ To disable telemetry, set the environment variable: export FIRECRAWL_NO_TELEMETRY=1 ``` -Requests to endpoints that accept an integration tag (scrape, search, map, crawl, agent, parse, interact, browser, feedback and the research, developer and gov searches) send `integration: cli`. A tool that wraps the CLI can tag those requests as its own by setting `FIRECRAWL_INTEGRATION` to a value that starts with an underscore (surrounding whitespace is ignored); other values are ignored and the tag stays `cli`: +Requests to endpoints that accept an integration tag (scrape, search, map, crawl, agent, parse, interact, browser, feedback, search-feedback and the research, developer and gov searches) send `integration: cli`. A tool that wraps the CLI can tag those requests as its own by setting `FIRECRAWL_INTEGRATION` to a value that starts with an underscore (surrounding whitespace is ignored); other values are ignored and the tag stays `cli`: ```bash export FIRECRAWL_INTEGRATION=_my-tool From 3910162b22b1e1e3229215f9f6285c1e8224db77 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:36:12 +0000 Subject: [PATCH 14/16] feat(cli): send FIRECRAWL_INTEGRATION as the X-Integration header on every API request The body integration stays cli. The CLI keeps no list of names: the API ignores a value it does not recognize, so the tag can never fail a request. --- README.md | 4 +- src/__tests__/commands/developer.test.ts | 15 ---- src/__tests__/commands/feedback.test.ts | 10 ++- src/__tests__/commands/scrape.test.ts | 38 -------- src/__tests__/utils/integration.test.ts | 105 +++++++++++++++++++---- src/commands/agent.ts | 3 +- src/commands/alexandria.ts | 3 +- src/commands/browser.ts | 5 +- src/commands/crawl.ts | 3 +- src/commands/credit-usage.ts | 2 + src/commands/developer.ts | 3 +- src/commands/doctor.ts | 4 + src/commands/feedback.ts | 5 +- src/commands/gov.ts | 3 +- src/commands/interact.ts | 9 +- src/commands/list.ts | 2 + src/commands/map.ts | 3 +- src/commands/monitor.ts | 2 + src/commands/parse.ts | 15 ++-- src/commands/research.ts | 3 +- src/commands/scrape.ts | 5 +- src/commands/search-feedback.ts | 5 +- src/commands/search.ts | 5 +- src/commands/status.ts | 3 + src/commands/terms.ts | 2 + src/utils/client.ts | 17 +++- src/utils/integration.ts | 25 ++++-- 27 files changed, 175 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index b9ce08a813..48ad54922c 100644 --- a/README.md +++ b/README.md @@ -1063,10 +1063,10 @@ To disable telemetry, set the environment variable: export FIRECRAWL_NO_TELEMETRY=1 ``` -Requests to endpoints that accept an integration tag (scrape, search, map, crawl, agent, parse, interact, browser, feedback, search-feedback and the research, developer and gov searches) send `integration: cli`. A tool that wraps the CLI can tag those requests as its own by setting `FIRECRAWL_INTEGRATION` to a value that starts with an underscore (surrounding whitespace is ignored); other values are ignored and the tag stays `cli`: +Requests from the CLI are tagged `integration: cli`. A tool that wraps the CLI can tag them as its own by setting `FIRECRAWL_INTEGRATION`: the CLI sends the value, trimmed, as the `X-Integration` header on every API request, and the API attributes the request to it when it is a recognized integration name. An unrecognized value is ignored by the API and never makes a request fail. ```bash -export FIRECRAWL_INTEGRATION=_my-tool +export FIRECRAWL_INTEGRATION=my-tool ``` --- diff --git a/src/__tests__/commands/developer.test.ts b/src/__tests__/commands/developer.test.ts index a28107a71a..b41dbb1d4e 100644 --- a/src/__tests__/commands/developer.test.ts +++ b/src/__tests__/commands/developer.test.ts @@ -84,21 +84,6 @@ describe('handleDeveloperSearchCommand', () => { ); }); - it('tags the query with FIRECRAWL_INTEGRATION when set', async () => { - vi.stubEnv('FIRECRAWL_INTEGRATION', '_rtk'); - mockHttpGet.mockResolvedValue(mockDeveloperResponse([sampleResult])); - - try { - await handleDeveloperSearchCommand({ query: 'tokio spawn_blocking' }); - } finally { - vi.unstubAllEnvs(); - } - - expect(mockHttpGet).toHaveBeenCalledWith( - '/v2/search/developer?query=tokio+spawn_blocking&integration=_rtk' - ); - }); - it('passes apiUrl and apiKey to getClient when provided', async () => { mockHttpGet.mockResolvedValue(mockDeveloperResponse([])); diff --git a/src/__tests__/commands/feedback.test.ts b/src/__tests__/commands/feedback.test.ts index a4023ac9b4..2e523b6484 100644 --- a/src/__tests__/commands/feedback.test.ts +++ b/src/__tests__/commands/feedback.test.ts @@ -160,8 +160,8 @@ describe('executeEndpointFeedback', () => { }); }); - it('sends the FIRECRAWL_INTEGRATION tag in endpoint and search feedback', async () => { - vi.stubEnv('FIRECRAWL_INTEGRATION', '_rtk'); + it('sends FIRECRAWL_INTEGRATION as X-Integration on endpoint and search feedback', async () => { + vi.stubEnv('FIRECRAWL_INTEGRATION', 'rtk'); mockFetch.mockResolvedValue({ ok: true, status: 200, @@ -181,18 +181,20 @@ describe('executeEndpointFeedback', () => { const [endpointUrl, endpointRequest] = mockFetch.mock.calls[0]; expect(endpointUrl).toBe('https://api.firecrawl.dev/v2/feedback'); + expect(endpointRequest.headers['X-Integration']).toBe('rtk'); expect(JSON.parse(endpointRequest.body)).toMatchObject({ origin: 'cli', - integration: '_rtk', + integration: 'cli', }); const [searchUrl, searchRequest] = mockFetch.mock.calls[1]; expect(searchUrl).toBe( 'https://api.firecrawl.dev/v2/search/search-1/feedback' ); + expect(searchRequest.headers['X-Integration']).toBe('rtk'); expect(JSON.parse(searchRequest.body)).toEqual({ rating: 'good', origin: 'cli', - integration: '_rtk', + integration: 'cli', }); }); diff --git a/src/__tests__/commands/scrape.test.ts b/src/__tests__/commands/scrape.test.ts index 46bb93eb32..b9c9606882 100644 --- a/src/__tests__/commands/scrape.test.ts +++ b/src/__tests__/commands/scrape.test.ts @@ -94,44 +94,6 @@ describe('executeScrape', () => { expect(mockClient.scrape).not.toHaveBeenCalled(); }); - it('sends the FIRECRAWL_INTEGRATION tag instead of cli', async () => { - vi.stubEnv('FIRECRAWL_INTEGRATION', '_rtk'); - mockClient.scrape.mockResolvedValue({ markdown: '# Test Content' }); - vi.mocked(isKeylessMode).mockReturnValueOnce(true); - vi.mocked(keylessRequest).mockResolvedValueOnce({ - data: { markdown: '# Test Content' }, - }); - try { - await executeScrape({ url: 'https://example.com' }); - await executeScrape({ url: 'https://example.com' }); - } finally { - vi.unstubAllEnvs(); - } - expect(keylessRequest).toHaveBeenCalledWith('/v2/scrape', { - url: 'https://example.com', - formats: ['markdown'], - integration: '_rtk', - }); - expect(mockClient.scrape).toHaveBeenCalledWith('https://example.com', { - formats: ['markdown'], - integration: '_rtk', - }); - }); - - it('ignores a FIRECRAWL_INTEGRATION tag without a leading underscore', async () => { - vi.stubEnv('FIRECRAWL_INTEGRATION', 'rtk'); - mockClient.scrape.mockResolvedValue({ markdown: '# Test Content' }); - try { - await executeScrape({ url: 'https://example.com' }); - } finally { - vi.unstubAllEnvs(); - } - expect(mockClient.scrape).toHaveBeenCalledWith('https://example.com', { - formats: ['markdown'], - integration: 'cli', - }); - }); - it('should pass apiUrl to getClient when provided', async () => { const mockResponse = { markdown: '# Test Content' }; mockClient.scrape.mockResolvedValue(mockResponse); diff --git a/src/__tests__/utils/integration.test.ts b/src/__tests__/utils/integration.test.ts index 48b09f64a8..d2d998b567 100644 --- a/src/__tests__/utils/integration.test.ts +++ b/src/__tests__/utils/integration.test.ts @@ -1,35 +1,110 @@ /** - * Tests for the integration tag + * Tests for the X-Integration header */ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getIntegration } from '../../utils/integration'; +import http from 'http'; +import type { AddressInfo } from 'net'; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { getClient, keylessRequest, resetClient } from '../../utils/client'; +import { initializeConfig, resetConfig } from '../../utils/config'; +import { integrationHeaders } from '../../utils/integration'; -describe('getIntegration', () => { +describe('integrationHeaders', () => { afterEach(() => { vi.unstubAllEnvs(); }); - it('defaults to cli', () => { + it('sends nothing when FIRECRAWL_INTEGRATION is unset or blank', () => { vi.stubEnv('FIRECRAWL_INTEGRATION', undefined); - expect(getIntegration()).toBe('cli'); + expect(integrationHeaders()).toEqual({}); + vi.stubEnv('FIRECRAWL_INTEGRATION', ' '); + expect(integrationHeaders()).toEqual({}); }); - it('uses FIRECRAWL_INTEGRATION when it starts with an underscore', () => { - vi.stubEnv('FIRECRAWL_INTEGRATION', '_rtk'); - expect(getIntegration()).toBe('_rtk'); - }); + it.each(['rtk', 'hermes', '_rtk', 'not a valid tag!'])( + 'sends %j as is, with no list to match', + (value) => { + vi.stubEnv('FIRECRAWL_INTEGRATION', value); + expect(integrationHeaders()).toEqual({ 'X-Integration': value }); + } + ); it('trims surrounding whitespace', () => { - vi.stubEnv('FIRECRAWL_INTEGRATION', ' _rtk\n'); - expect(getIntegration()).toBe('_rtk'); + vi.stubEnv('FIRECRAWL_INTEGRATION', ' hermes\n'); + expect(integrationHeaders()).toEqual({ 'X-Integration': 'hermes' }); }); - it.each(['', ' ', 'rtk', 'zapier', 'cli_', ' x_rtk'])( - 'ignores %j and falls back to cli', + it.each(['bad\nvalue', 'tag\u2713'])( + 'drops %j, which cannot be sent as a header', (value) => { vi.stubEnv('FIRECRAWL_INTEGRATION', value); - expect(getIntegration()).toBe('cli'); + expect(integrationHeaders()).toEqual({}); } ); }); + +describe('X-Integration on the wire', () => { + const received: http.IncomingHttpHeaders[] = []; + let server: http.Server; + let apiUrl: string; + + beforeAll(async () => { + server = http.createServer((req, res) => { + received.push(req.headers); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: {} })); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + received.length = 0; + resetClient(); + resetConfig(); + }); + + it.each(['rtk', 'hermes', '_rtk'])( + 'puts %j on SDK client requests while the body keeps integration cli', + async (value) => { + vi.stubEnv('FIRECRAWL_INTEGRATION', value); + const client = getClient({ apiKey: 'fc-test', apiUrl }); + await (client as any).http.post('/v2/scrape', { integration: 'cli' }); + await (client as any).http.get('/v2/search/developer?integration=cli'); + expect(received.map((h) => h['x-integration'])).toEqual([value, value]); + } + ); + + it('lets a request with a junk value succeed', async () => { + vi.stubEnv('FIRECRAWL_INTEGRATION', 'not a valid tag!'); + initializeConfig({ apiUrl }); + await expect( + keylessRequest('/v2/scrape', { url: 'https://example.com' }) + ).resolves.toEqual({ success: true, data: {} }); + expect(received[0]['x-integration']).toBe('not a valid tag!'); + }); + + it('lets a request succeed without the header when the value cannot be sent', async () => { + vi.stubEnv('FIRECRAWL_INTEGRATION', 'bad\nvalue'); + const client = getClient({ apiKey: 'fc-test', apiUrl }); + await expect( + (client as any).http.post('/v2/scrape', { integration: 'cli' }) + ).resolves.toMatchObject({ status: 200 }); + expect(received[0]['x-integration']).toBeUndefined(); + }); +}); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index bf8e798e66..23603ad204 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -27,7 +27,6 @@ import { writeOutput } from '../utils/output'; import { createSpinner } from '../utils/spinner'; import { TERMS_DASHBOARD_URL } from './terms'; import { readFileSync } from 'fs'; -import { getIntegration } from '../utils/integration'; /** * Extract detailed error message from API errors @@ -310,7 +309,7 @@ export async function executeAgent( integration?: string; } = { prompt, - integration: getIntegration(), + integration: 'cli', }; if (options.urls && options.urls.length > 0) { diff --git a/src/commands/alexandria.ts b/src/commands/alexandria.ts index 9703384b8f..2949e400c7 100644 --- a/src/commands/alexandria.ts +++ b/src/commands/alexandria.ts @@ -7,7 +7,6 @@ import { getApiKey } from '../utils/config'; import { writeOutput } from '../utils/output'; import { withoutResultPrices } from '../utils/alexandria-prices'; import { TERMS_DASHBOARD_URL } from './terms'; -import { getIntegration } from '../utils/integration'; type Call = AlexandriaCall & { options: Record }; export type AlexandriaOptions = { @@ -214,7 +213,7 @@ export async function requestAlexandria( const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); const result = await app.scrape({ alexandria: calls, - integration: getIntegration(), + integration: 'cli', timeout: options.timeout, requestId, }); diff --git a/src/commands/browser.ts b/src/commands/browser.ts index cff499f216..c433cdeec1 100644 --- a/src/commands/browser.ts +++ b/src/commands/browser.ts @@ -12,7 +12,6 @@ import { getSessionId, } from '../utils/browser-session'; import { writeOutput } from '../utils/output'; -import { getIntegration } from '../utils/integration'; export interface BrowserLaunchOptions { ttl?: number; @@ -78,7 +77,7 @@ export async function handleBrowserLaunch( saveChanges?: boolean; }; integration?: string; - } = { integration: getIntegration() }; + } = { integration: 'cli' }; if (options.ttl !== undefined) args.ttl = options.ttl; if (options.ttlInactivity !== undefined) args.activityTtl = options.ttlInactivity; @@ -298,7 +297,7 @@ export async function handleBrowserQuickExecute( saveChanges?: boolean; }; integration?: string; - } = { integration: getIntegration() }; + } = { integration: 'cli' }; if (options.profile) { launchArgs.profile = { name: options.profile, diff --git a/src/commands/crawl.ts b/src/commands/crawl.ts index 0c16e3df4b..0242956363 100644 --- a/src/commands/crawl.ts +++ b/src/commands/crawl.ts @@ -10,7 +10,6 @@ import type { import { getClient } from '../utils/client'; import { isJobId } from '../utils/job'; import { writeOutput } from '../utils/output'; -import { getIntegration } from '../utils/integration'; /** * Execute crawl status check @@ -86,7 +85,7 @@ export async function executeCrawl( // Build crawl options const crawlOptions: any = { - integration: getIntegration(), + integration: 'cli', }; if (options.limit !== undefined) { diff --git a/src/commands/credit-usage.ts b/src/commands/credit-usage.ts index 117d921003..7b3c02be4f 100644 --- a/src/commands/credit-usage.ts +++ b/src/commands/credit-usage.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getConfig, validateConfig } from '../utils/config'; import { getClient } from '../utils/client'; +import { integrationHeaders } from '../utils/integration'; export interface CreditUsageResult { success: boolean; @@ -58,6 +59,7 @@ export async function executeCreditUsage( headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', + ...integrationHeaders(), }, }); diff --git a/src/commands/developer.ts b/src/commands/developer.ts index 35e7183234..5d2f525e39 100644 --- a/src/commands/developer.ts +++ b/src/commands/developer.ts @@ -8,7 +8,6 @@ import type { DeveloperSearchResponse, DeveloperSourceStatus, } from '../types/developer'; -import { getIntegration } from '../utils/integration'; // The other mount, /v2/developer/search, rejects keyless callers and may be // withdrawn. @@ -18,7 +17,7 @@ async function getDeveloper( path: string, options: DeveloperSearchOptions ): Promise { - const url = `${path}${path.includes('?') ? '&' : '?'}integration=${encodeURIComponent(getIntegration())}`; + const url = `${path}${path.includes('?') ? '&' : '?'}integration=cli`; if (isKeylessMode(options.apiKey, options.apiUrl)) { return (await keylessGet(url)) as T; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index c6839fffd6..aea91af301 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -17,6 +17,7 @@ import { isJobId } from '../utils/job'; import { detectAgents, type AgentDetection } from '../utils/agents'; import { compareVersions, getLatestVersion } from '../utils/npm-registry'; import { getAuthSource, type AuthSource } from './status'; +import { integrationHeaders } from '../utils/integration'; type CheckStatus = 'pass' | 'warn' | 'fail'; type DoctorAuthSource = AuthSource | 'flag'; @@ -121,6 +122,7 @@ async function pingCreditUsage( headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', + ...integrationHeaders(), }, }); const durationMs = Date.now() - start; @@ -156,6 +158,7 @@ async function fetchQueueStatus( headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', + ...integrationHeaders(), }, }); if (!response.ok) { @@ -698,6 +701,7 @@ export async function runSupportAsk(options: DoctorOptions): Promise { headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', + ...integrationHeaders(), }, body: JSON.stringify({ question, jobId }), }); diff --git a/src/commands/feedback.ts b/src/commands/feedback.ts index 2351cc39ae..215626403d 100644 --- a/src/commands/feedback.ts +++ b/src/commands/feedback.ts @@ -11,7 +11,7 @@ import { type ValuableResultInput, type ValuableSourceInput, } from './search-feedback'; -import { getIntegration } from '../utils/integration'; +import { integrationHeaders } from '../utils/integration'; export type EndpointFeedbackEndpoint = 'search' | 'scrape' | 'parse' | 'map'; @@ -265,7 +265,7 @@ export async function executeEndpointFeedback( ...(options.endpoint === 'alexandria' ? {} : { jobId: options.jobId }), rating: options.rating, origin: 'cli', - integration: getIntegration(), + integration: 'cli', }; if (options.endpoint !== 'alexandria' && !options.jobId) { @@ -314,6 +314,7 @@ export async function executeEndpointFeedback( headers: { ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), 'Content-Type': 'application/json', + ...integrationHeaders(), }, body: JSON.stringify(body), }); diff --git a/src/commands/gov.ts b/src/commands/gov.ts index ca0d677281..f92f679c2e 100644 --- a/src/commands/gov.ts +++ b/src/commands/gov.ts @@ -5,12 +5,11 @@ import type { GovSearchOptions, GovSearchResponse, } from '../types/gov'; -import { getIntegration } from '../utils/integration'; const BASE = '/v2/search/gov'; async function getGov(path: string, options: GovSearchOptions): Promise { - const url = `${path}${path.includes('?') ? '&' : '?'}integration=${encodeURIComponent(getIntegration())}`; + const url = `${path}${path.includes('?') ? '&' : '?'}integration=cli`; if (isKeylessMode(options.apiKey, options.apiUrl)) { return (await keylessGet(url)) as T; diff --git a/src/commands/interact.ts b/src/commands/interact.ts index 8fb550e270..c42b810b12 100644 --- a/src/commands/interact.ts +++ b/src/commands/interact.ts @@ -11,7 +11,7 @@ import { clearInteractSession, } from '../utils/interact-session'; import { writeOutput } from '../utils/output'; -import { getIntegration } from '../utils/integration'; +import { integrationHeaders } from '../utils/integration'; export interface InteractExecuteOptions { scrapeId?: string; @@ -61,7 +61,7 @@ function buildHeaders(apiKey: string | undefined, keyless: boolean) { headers.Authorization = `Bearer ${apiKey}`; } if (keyless) Object.assign(headers, KEYLESS_CLI_HEADERS); - return headers; + return { ...headers, ...integrationHeaders() }; } /** @@ -81,10 +81,7 @@ export async function handleInteractExecute( `Using scrape ${scrapeId}` + (storedUrl ? ` (${storedUrl})` : '') + '\n' ); - const body: Record = { - origin: 'cli', - integration: getIntegration(), - }; + const body: Record = { origin: 'cli', integration: 'cli' }; if (options.code) { body.code = options.code; diff --git a/src/commands/list.ts b/src/commands/list.ts index 044c4cf23b..829a2be6cf 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -11,6 +11,7 @@ import { } from './alexandria'; import { writeOutput } from '../utils/output'; import { getApiKey, getConfig } from '../utils/config'; +import { integrationHeaders } from '../utils/integration'; type Selectors = Record; type ListOptions = AlexandriaOptions & { @@ -97,6 +98,7 @@ async function requestCategories( headers: { Authorization: `Bearer ${getApiKey(options.apiKey)}`, 'X-Request-ID': requestId, + ...integrationHeaders(), }, signal: AbortSignal.timeout(getConfig().timeoutMs ?? 30000), redirect: 'error', diff --git a/src/commands/map.ts b/src/commands/map.ts index 4692f35d6a..bbac27be84 100644 --- a/src/commands/map.ts +++ b/src/commands/map.ts @@ -5,7 +5,6 @@ import type { MapOptions, MapResult } from '../types/map'; import { getClient } from '../utils/client'; import { writeOutput } from '../utils/output'; -import { getIntegration } from '../utils/integration'; /** * Execute map command @@ -17,7 +16,7 @@ export async function executeMap(options: MapOptions): Promise { // Build map options const mapOptions: any = { - integration: getIntegration(), + integration: 'cli', }; if (options.limit !== undefined) { diff --git a/src/commands/monitor.ts b/src/commands/monitor.ts index 7cf26cf61f..f0eb018286 100644 --- a/src/commands/monitor.ts +++ b/src/commands/monitor.ts @@ -20,6 +20,7 @@ import * as fs from 'fs'; import { Command } from 'commander'; import { getConfig, validateConfig } from '../utils/config'; import { writeOutput } from '../utils/output'; +import { integrationHeaders } from '../utils/integration'; const DEFAULT_API_URL = 'https://api.firecrawl.dev'; @@ -63,6 +64,7 @@ async function monitorRequest( const headers: Record = { Authorization: `Bearer ${apiKey}`, 'X-Origin': 'cli', + ...integrationHeaders(), }; if (init.body !== undefined) headers['Content-Type'] = 'application/json'; diff --git a/src/commands/parse.ts b/src/commands/parse.ts index 8645c8215c..cd852b23c6 100644 --- a/src/commands/parse.ts +++ b/src/commands/parse.ts @@ -14,7 +14,7 @@ import type { ScrapeFormat } from '../types/scrape'; import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; import { handleScrapeOutput } from '../utils/output'; -import { getIntegration } from '../utils/integration'; +import { integrationHeaders } from '../utils/integration'; const DEFAULT_API_URL = 'https://api.firecrawl.dev'; @@ -96,7 +96,7 @@ function buildFormats(options: ParseOptions): FormatOption[] { function buildOptionsPayload(options: ParseOptions): Record { const payload: Record = { formats: buildFormats(options), - integration: getIntegration(), + integration: 'cli', }; if (options.onlyMainContent !== undefined) { @@ -187,12 +187,11 @@ export async function executeParse( method: 'POST', // Multipart options are parsed after auth, so the header carries the // CLI origin to the keyless check. - headers: - !keyless && apiKey - ? { Authorization: `Bearer ${apiKey}` } - : keyless - ? { ...KEYLESS_CLI_HEADERS } - : undefined, + headers: { + ...(!keyless && apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + ...(keyless ? KEYLESS_CLI_HEADERS : {}), + ...integrationHeaders(), + }, body: form, }); diff --git a/src/commands/research.ts b/src/commands/research.ts index 784d73f157..45a03ddc4b 100644 --- a/src/commands/research.ts +++ b/src/commands/research.ts @@ -10,7 +10,6 @@ import type { SearchGitHubOptions, SearchPapersOptions, } from '../types/research'; -import { getIntegration } from '../utils/integration'; const BASE = '/v2/search/research'; const MAX_AUTHORS = 15; @@ -45,7 +44,7 @@ async function getResearch( path: string, options: ResearchBaseOptions ): Promise { - const url = `${path}${path.includes('?') ? '&' : '?'}integration=${encodeURIComponent(getIntegration())}`; + const url = `${path}${path.includes('?') ? '&' : '?'}integration=cli`; if (isKeylessMode(options.apiKey, options.apiUrl)) { return (await keylessGet(url)) as T; diff --git a/src/commands/scrape.ts b/src/commands/scrape.ts index f1c36709d6..96fd28078d 100644 --- a/src/commands/scrape.ts +++ b/src/commands/scrape.ts @@ -20,7 +20,6 @@ import { executeMap } from './map'; import { getStatus } from './status'; import { requireAlexandriaKey } from './alexandria'; import { withoutToolPrice } from '../utils/alexandria-prices'; -import { getIntegration } from '../utils/integration'; /** * Output timing information if requested @@ -94,7 +93,7 @@ export async function executeScrape( const scrapeParams: Record = { formats: resolvedFormats, - integration: getIntegration(), + integration: 'cli', }; if (options.onlyMainContent !== undefined) { @@ -158,7 +157,7 @@ export async function executeScrape( let result: any; if (isKeylessMode(options.apiKey, options.apiUrl)) { // Keyless free tier: header-less request. The API identifies the CLI via - // the `integration` field already in scrapeParams. + // the `integration: 'cli'` field already in scrapeParams. const json = await keylessRequest('/v2/scrape', { url: options.url, ...scrapeParams, diff --git a/src/commands/search-feedback.ts b/src/commands/search-feedback.ts index 8922e4fb68..66dcdf0fb7 100644 --- a/src/commands/search-feedback.ts +++ b/src/commands/search-feedback.ts @@ -1,6 +1,6 @@ import { getConfig, validateConfig } from '../utils/config'; import { getClient } from '../utils/client'; -import { getIntegration } from '../utils/integration'; +import { integrationHeaders } from '../utils/integration'; export type SearchFeedbackRating = 'good' | 'bad' | 'partial'; @@ -126,7 +126,7 @@ export async function executeSearchFeedback( const body: Record = { rating: options.rating, origin: 'cli', - integration: getIntegration(), + integration: 'cli', }; if (options.valuableSources && options.valuableSources.length > 0) { @@ -157,6 +157,7 @@ export async function executeSearchFeedback( headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', + ...integrationHeaders(), }, body: JSON.stringify(body), }); diff --git a/src/commands/search.ts b/src/commands/search.ts index 70dcc002e2..39e8449a7e 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -18,7 +18,6 @@ import { getApiKey } from '../utils/config'; import { writeOutput } from '../utils/output'; import { withoutToolPrice } from '../utils/alexandria-prices'; import { apiFailure, requireAlexandriaKey } from './alexandria'; -import { getIntegration } from '../utils/integration'; const DEFAULT_SEARCH_LIMIT = 5; @@ -40,7 +39,7 @@ export async function executeSearch( // Build search options for the SDK const searchParams: Record = { limit: options.limit ?? DEFAULT_SEARCH_LIMIT, - integration: getIntegration(), + integration: 'cli', }; searchParams.toolDetail = options.toolDetail ?? 'compact'; @@ -129,7 +128,7 @@ export async function executeSearch( let envelope: Record; if (isKeylessMode(options.apiKey, options.apiUrl)) { // Keyless free tier: header-less request. The API identifies the CLI via - // the `integration` field already in searchParams. + // the `integration: 'cli'` field already in searchParams. envelope = (await keylessRequest('/v2/search', searchBody)) as Record< string, any diff --git a/src/commands/status.ts b/src/commands/status.ts index b1c1ca15f8..b9a81cc2a1 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -10,6 +10,7 @@ import { isAuthenticated } from '../utils/auth'; import { getConfig, validateConfig } from '../utils/config'; import { loadCredentials } from '../utils/credentials'; import { isSearchFeedbackDisabledLocally } from './search-feedback'; +import { integrationHeaders } from '../utils/integration'; export type AuthSource = 'env' | 'stored' | 'none'; @@ -81,6 +82,7 @@ async function fetchQueueStatus( headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', + ...integrationHeaders(), }, }); @@ -107,6 +109,7 @@ async function fetchCreditUsage( headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', + ...integrationHeaders(), }, }); diff --git a/src/commands/terms.ts b/src/commands/terms.ts index 7b03e1bedc..52dd0f97e8 100644 --- a/src/commands/terms.ts +++ b/src/commands/terms.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { getApiKey, getConfig } from '../utils/config'; import { writeOutput } from '../utils/output'; +import { integrationHeaders } from '../utils/integration'; type TermsOptions = { apiKey?: string; @@ -42,6 +43,7 @@ export async function requestTerms( headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', + ...integrationHeaders(), }, redirect: 'error', signal: AbortSignal.timeout(getConfig().timeoutMs ?? 30000), diff --git a/src/utils/client.ts b/src/utils/client.ts index d61d16e89a..d94be2a50e 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -13,6 +13,7 @@ import { updateConfig, type GlobalConfig, } from './config'; +import { integrationHeaders, withIntegrationHeader } from './integration'; let clientInstance: Firecrawl | null = null; @@ -47,7 +48,11 @@ export async function keylessRequest( const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); const response = await fetch(`${apiUrl}${path}`, { method: 'POST', - headers: { 'Content-Type': 'application/json', ...KEYLESS_CLI_HEADERS }, + headers: { + 'Content-Type': 'application/json', + ...KEYLESS_CLI_HEADERS, + ...integrationHeaders(), + }, body: JSON.stringify(body), }); const json: any = await response.json().catch(() => ({})); @@ -63,7 +68,11 @@ export async function keylessGet(path: string): Promise { const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); const response = await fetch(`${apiUrl}${path}`, { method: 'GET', - headers: { 'Content-Type': 'application/json', ...KEYLESS_CLI_HEADERS }, + headers: { + 'Content-Type': 'application/json', + ...KEYLESS_CLI_HEADERS, + ...integrationHeaders(), + }, }); const json: any = await response.json().catch(() => ({})); if (!response.ok) { @@ -128,7 +137,7 @@ export function getClient( backoffFactor: options.backoffFactor ?? config.backoffFactor, }; - return new Firecrawl(clientOptions); + return withIntegrationHeader(new Firecrawl(clientOptions)); } // Return singleton instance or create one @@ -144,7 +153,7 @@ export function getClient( backoffFactor: config.backoffFactor, }; - clientInstance = new Firecrawl(clientOptions); + clientInstance = withIntegrationHeader(new Firecrawl(clientOptions)); } return clientInstance; diff --git a/src/utils/integration.ts b/src/utils/integration.ts index ee8bc71ff4..18227b5a16 100644 --- a/src/utils/integration.ts +++ b/src/utils/integration.ts @@ -1,9 +1,24 @@ +import type { Firecrawl } from 'firecrawl'; + /** - * The `integration` tag sent with API requests. A tool that wraps the CLI can - * set `FIRECRAWL_INTEGRATION` to a value starting with `_`; anything else - * falls back to `cli`. + * FIRECRAWL_INTEGRATION as an X-Integration header, for every API request. + * The API ignores a value it does not recognize, so the CLI keeps no list; it + * only drops values that cannot be sent as a header at all. */ -export function getIntegration(): string { +export function integrationHeaders(): Record { const tag = process.env.FIRECRAWL_INTEGRATION?.trim(); - return tag?.startsWith('_') ? tag : 'cli'; + return tag && /^[\x20-\x7e]+$/.test(tag) ? { 'X-Integration': tag } : {}; +} + +/** Adds the integration header to every request an SDK client sends. */ +export function withIntegrationHeader(client: T): T { + const defaults = ( + client as unknown as { + http?: { + instance?: { defaults?: { headers?: Record } }; + }; + } + ).http?.instance?.defaults?.headers; + if (defaults) Object.assign(defaults, integrationHeaders()); + return client; } From e9251a3e4a4ce051c6f20c080f15b19931a217f2 Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:44:48 +0000 Subject: [PATCH 15/16] test(doctor): assert X-Integration on doctor requests; fix the README example and keyless header comment --- README.md | 4 +-- src/__tests__/commands/doctor.test.ts | 40 +++++++++++++++++++++++++++ src/utils/client.ts | 3 +- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 48ad54922c..554d5dfa60 100644 --- a/README.md +++ b/README.md @@ -1063,10 +1063,10 @@ To disable telemetry, set the environment variable: export FIRECRAWL_NO_TELEMETRY=1 ``` -Requests from the CLI are tagged `integration: cli`. A tool that wraps the CLI can tag them as its own by setting `FIRECRAWL_INTEGRATION`: the CLI sends the value, trimmed, as the `X-Integration` header on every API request, and the API attributes the request to it when it is a recognized integration name. An unrecognized value is ignored by the API and never makes a request fail. +Requests from the CLI are tagged `integration: cli`. A tool that wraps the CLI can tag them as its own by setting `FIRECRAWL_INTEGRATION`: the CLI sends the value, trimmed, as the `X-Integration` header on every API request, and the API attributes the request to it when it is one of its integration names or any value starting with an underscore. Any other value is ignored by the API and never makes a request fail. ```bash -export FIRECRAWL_INTEGRATION=my-tool +export FIRECRAWL_INTEGRATION=_my-tool ``` --- diff --git a/src/__tests__/commands/doctor.test.ts b/src/__tests__/commands/doctor.test.ts index a66a98adf8..29737b1038 100644 --- a/src/__tests__/commands/doctor.test.ts +++ b/src/__tests__/commands/doctor.test.ts @@ -158,6 +158,27 @@ describe('runChecks', () => { return c; } + it('sends FIRECRAWL_INTEGRATION as X-Integration on its API pings', async () => { + vi.stubEnv('FIRECRAWL_INTEGRATION', 'hermes'); + initializeConfig({ + apiKey: 'fc-abc123def456', + apiUrl: 'https://api.firecrawl.dev', + }); + stubFetch({}); + try { + await runChecks({}); + } finally { + vi.unstubAllEnvs(); + } + const apiCalls = mockFetch.mock.calls.filter(([url]) => + String(url).startsWith('https://api.firecrawl.dev/') + ); + expect(apiCalls).toHaveLength(2); + for (const [, init] of apiCalls) { + expect(init.headers['X-Integration']).toBe('hermes'); + } + }); + it('fails the API Key check when no key is configured', async () => { stubFetch({}); const { checks } = await runChecks({}); @@ -400,6 +421,25 @@ describe('runSupportAsk', () => { ); }); + it('sends FIRECRAWL_INTEGRATION as X-Integration to support Ask', async () => { + vi.stubEnv('FIRECRAWL_INTEGRATION', 'hermes'); + initializeConfig({ + apiKey: 'fc-test', + apiUrl: 'https://api.firecrawl.dev', + }); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ answer: 'Try increasing waitFor.' }), + }); + try { + await runSupportAsk({ jobId, query: 'why is it empty?' }); + } finally { + vi.unstubAllEnvs(); + } + expect(mockFetch.mock.calls[0][1].headers['X-Integration']).toBe('hermes'); + }); + it('does not call support Ask without an API key', async () => { const exitCode = await runSupportAsk({ jobId }); diff --git a/src/utils/client.ts b/src/utils/client.ts index d94be2a50e..0ce35d37cd 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -33,7 +33,8 @@ export function isKeylessMode(apiKey?: string, apiUrl?: string): boolean { /** * Headers for keyless requests. The API reads X-Origin to attribute keyless * use, and a keyless prompt's signup link, to the CLI; requests without a body - * (GET research and developer lookups, interact stop) carry nothing else. + * (GET research and developer lookups, interact stop) carry only this and + * X-Integration. * Keyless error messages are printed as the API sends them: their * firecrawl.dev/k/ link already resolves to CLI attribution. */ From ada5fde75181850d3b8c7d0b30b05619f6b951dc Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:49:14 +0000 Subject: [PATCH 16/16] docs(readme): say a value that cannot be a header is not sent --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 554d5dfa60..e2a468eb40 100644 --- a/README.md +++ b/README.md @@ -1063,7 +1063,7 @@ To disable telemetry, set the environment variable: export FIRECRAWL_NO_TELEMETRY=1 ``` -Requests from the CLI are tagged `integration: cli`. A tool that wraps the CLI can tag them as its own by setting `FIRECRAWL_INTEGRATION`: the CLI sends the value, trimmed, as the `X-Integration` header on every API request, and the API attributes the request to it when it is one of its integration names or any value starting with an underscore. Any other value is ignored by the API and never makes a request fail. +Requests from the CLI are tagged `integration: cli`. A tool that wraps the CLI can tag them as its own by setting `FIRECRAWL_INTEGRATION`: the CLI sends the value, trimmed, as the `X-Integration` header on every API request (a value with control or non-ASCII characters cannot be a header and is not sent), and the API attributes the request to it when it is one of its integration names or any value starting with an underscore. Any other value is ignored by the API and never makes a request fail. ```bash export FIRECRAWL_INTEGRATION=_my-tool