diff --git a/src/commands/scrape.ts b/src/commands/scrape.ts index 67b31bbd7..c4d86f7c1 100644 --- a/src/commands/scrape.ts +++ b/src/commands/scrape.ts @@ -9,7 +9,7 @@ import type { ScrapeFormat, ScrapeLocation, } from '../types/scrape'; -import { getClient } from '../utils/client'; +import { getClient, isKeylessMode, keylessRequest } from '../utils/client'; import { handleScrapeOutput, writeOutput } from '../utils/output'; import { saveInteractSession, @@ -57,9 +57,6 @@ function outputTiming( export async function executeScrape( options: ScrapeOptions ): Promise { - // Get client instance (updates global config if apiKey/apiUrl provided) - const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); - // Build scrape options const formats: FormatOption[] = []; @@ -145,7 +142,22 @@ export async function executeScrape( const requestStartTime = Date.now(); try { - const result = await app.scrape(options.url, scrapeParams); + 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. + const json = await keylessRequest('/v2/scrape', { + url: options.url, + ...scrapeParams, + }); + result = json?.data ?? json; + } else { + const app = getClient({ + apiKey: options.apiKey, + apiUrl: options.apiUrl, + }); + result = await app.scrape(options.url, scrapeParams); + } const requestEndTime = Date.now(); outputTiming(options, requestStartTime, requestEndTime); diff --git a/src/commands/search.ts b/src/commands/search.ts index 91bd7d688..585066073 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -11,7 +11,7 @@ import type { ImageSearchResult, NewsSearchResult, } from '../types/search'; -import { getClient } from '../utils/client'; +import { getClient, isKeylessMode, keylessRequest } from '../utils/client'; import { writeOutput } from '../utils/output'; /** @@ -21,8 +21,6 @@ export async function executeSearch( options: SearchOptions ): Promise { try { - const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); - // Build search options for the SDK const searchParams: Record = { limit: options.limit, @@ -90,15 +88,31 @@ export async function executeSearch( searchParams.scrapeOptions = scrapeOptions; } + const searchBody = { + query: options.query, + ...searchParams, + }; + // Call /v2/search through the SDK's HTTP layer (auth + retries) instead // of `app.search()` so we keep the full response envelope. The high-level // `search()` helper drops `id` and `creditsUsed`, which breaks the // `firecrawl search-feedback ` workflow that consumers rely on. - const httpResponse = await (app as any).http.post('/v2/search', { - query: options.query, - ...searchParams, - }); - const envelope = (httpResponse?.data ?? {}) as Record; + 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. + envelope = (await keylessRequest('/v2/search', searchBody)) as Record< + string, + any + >; + } else { + const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); + const httpResponse = await (app as any).http.post( + '/v2/search', + searchBody + ); + envelope = (httpResponse?.data ?? {}) as Record; + } const payload = (envelope.data ?? {}) as Record; const data: SearchResultData = {}; diff --git a/src/index.ts b/src/index.ts index 44801d733..2b0a9ecf8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,14 +61,15 @@ import { createCreateCommand } from './commands/create'; // Initialize global configuration from environment variables initializeConfig(); -// Commands that require authentication +// Commands that require authentication. +// NOTE: `scrape` and `search` are intentionally excluded — they fall back to +// the keyless free tier (rate-limited per IP) when no API key is configured, so +// they must not prompt for login. They still use a configured key when present. const AUTH_REQUIRED_COMMANDS = [ - 'scrape', 'download', 'crawl', 'map', 'parse', - 'search', 'search-feedback', 'agent', 'browser', diff --git a/src/utils/client.ts b/src/utils/client.ts index d9e60e7c8..e6c738bbb 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -7,6 +7,8 @@ import { Firecrawl } from 'firecrawl'; import type { FirecrawlClientOptions } from 'firecrawl'; import { getConfig, + getApiKey, + isCustomApiUrl, validateConfig, updateConfig, type GlobalConfig, @@ -14,6 +16,38 @@ import { let clientInstance: Firecrawl | null = null; +const DEFAULT_API_URL = 'https://api.firecrawl.dev'; + +/** + * Keyless free tier: scrape and search work without an API key against the + * Firecrawl cloud (rate-limited per IP). The cloud only grants this when NO + * Authorization header is sent, so these requests bypass the SDK — which always + * attaches a Bearer header — and post directly. Only applies to the cloud + * default URL; a custom/self-hosted URL keeps its existing optional-auth path. + */ +export function isKeylessMode(apiKey?: string, apiUrl?: string): boolean { + return !getApiKey(apiKey) && !isCustomApiUrl(apiUrl); +} + +export async function keylessRequest( + path: string, + body: Record +): Promise { + const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); + const response = await fetch(`${apiUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const json: any = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error( + json?.error || `Firecrawl request failed (HTTP ${response.status})` + ); + } + return json; +} + /** * Get or create the Firecrawl client instance * Uses global configuration if available, otherwise creates with provided options