Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions src/commands/scrape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -57,9 +57,6 @@ function outputTiming(
export async function executeScrape(
options: ScrapeOptions
): Promise<ScrapeResult> {
// 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[] = [];

Expand Down Expand Up @@ -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);

Expand Down
30 changes: 22 additions & 8 deletions src/commands/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -21,8 +21,6 @@ export async function executeSearch(
options: SearchOptions
): Promise<SearchResult> {
try {
const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl });

// Build search options for the SDK
const searchParams: Record<string, any> = {
limit: options.limit,
Expand Down Expand Up @@ -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 <id>` 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<string, any>;
let envelope: Record<string, 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 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<string, any>;
}
const payload = (envelope.data ?? {}) as Record<string, any>;

const data: SearchResultData = {};
Expand Down
7 changes: 4 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
34 changes: 34 additions & 0 deletions src/utils/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,47 @@ import { Firecrawl } from 'firecrawl';
import type { FirecrawlClientOptions } from 'firecrawl';
import {
getConfig,
getApiKey,
isCustomApiUrl,
validateConfig,
updateConfig,
type GlobalConfig,
} from './config';

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<string, unknown>
): Promise<any> {
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
Expand Down
Loading