From e66974a54a1f02fe37d9c061e693707b6dfa707f Mon Sep 17 00:00:00 2001 From: Abimael Martell <1450169+abimaelmartell@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:16:11 -0700 Subject: [PATCH 1/6] feat(cli): add generic feedback command --- README.md | 38 +++ skills/firecrawl-cli/SKILL.md | 17 + src/__tests__/commands/feedback.test.ts | 152 +++++++++ src/commands/feedback.ts | 407 ++++++++++++++++++++++++ src/index.ts | 101 ++++++ 5 files changed, 715 insertions(+) create mode 100644 src/__tests__/commands/feedback.test.ts create mode 100644 src/commands/feedback.ts diff --git a/README.md b/README.md index de6aa9da32..56979bf6f4 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,44 @@ firecrawl search "AI startups funding" --sources news --tbs qdr:w --limit 15 --- +### `feedback` - Send endpoint job feedback + +Send concise feedback for a completed v2 `search`, `scrape`, `parse`, or `map` +job. For search-result quality, `search-feedback` is still the most guided +command; `feedback` is the generic endpoint/job surface. + +```bash +firecrawl feedback scrape 0193f6c5-1234-7890-abcd-1234567890ab \ + --rating partial \ + --issues missing_markdown \ + --tags docs \ + --note "The pricing table was missing from the markdown output." \ + --url https://example.com/pricing \ + --page-numbers 1 +``` + +Keep notes and metadata small. Do not send raw scrape or parse outputs as +feedback. + +#### Feedback Options + +| Option | Description | +| -------------------------------- | -------------------------------------------- | +| `--rating ` | Required: `good`, `partial`, or `bad` | +| `--issues ` | Comma-separated issue codes or JSON array | +| `--tags ` | Comma-separated tags or JSON array | +| `--note ` | Short human-readable feedback | +| `--valuable-sources ` | JSON array of `{url, reason}` entries | +| `--missing-content ` | JSON array of `{topic, description}` entries | +| `--query-suggestions ` | Search/query improvement notes | +| `--url ` | Relevant URL for scrape or parse feedback | +| `--page-numbers ` | Comma-separated page numbers or JSON array | +| `--metadata ` | Small JSON object with extra context | +| `--metadata-file ` | Path to small metadata JSON object | +| `--silent` | Suppress output for background agent calls | + +--- + ### `map` - Discover all URLs on a website Quickly discover all URLs on a website without scraping content. diff --git a/skills/firecrawl-cli/SKILL.md b/skills/firecrawl-cli/SKILL.md index 8105cc8721..ea9f925f25 100644 --- a/skills/firecrawl-cli/SKILL.md +++ b/skills/firecrawl-cli/SKILL.md @@ -279,6 +279,23 @@ The most useful field is `--missing-content`: an _array_ of specific pieces of c **Opt out:** `export FIRECRAWL_NO_SEARCH_FEEDBACK=1` makes the CLI skip every feedback call silently. Respect that flag — do not try to work around it. See [firecrawl-search](../firecrawl-search/SKILL.md) for the full pattern. +## Endpoint job feedback + +For non-search endpoint jobs, use `firecrawl feedback ` to send concise job-level feedback through `/v2/feedback`. Supported endpoints are `search`, `scrape`, `parse`, and `map`. + +```bash +firecrawl feedback scrape "$SCRAPE_ID" \ + --rating partial \ + --issues missing_markdown \ + --tags docs \ + --note "The pricing table was missing from the markdown output." \ + --url "https://example.com/pricing" \ + --page-numbers 1 \ + --silent & +``` + +Keep generic feedback small: issue codes, tags, short notes, URLs, page numbers, and small metadata objects. Do not send raw scrape/parse outputs or full page contents as feedback. + ## Parallelization Run independent operations in parallel. Check `firecrawl --status` for concurrency limit: diff --git a/src/__tests__/commands/feedback.test.ts b/src/__tests__/commands/feedback.test.ts new file mode 100644 index 0000000000..f3feddcde6 --- /dev/null +++ b/src/__tests__/commands/feedback.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + executeEndpointFeedback, + parseEndpointFeedbackEndpoint, + parseFeedbackListArg, + parsePageNumbersArg, +} from '../../commands/feedback'; +import { getClient } from '../../utils/client'; +import { initializeConfig } from '../../utils/config'; +import { setupTest, teardownTest } from '../utils/mock-client'; + +vi.mock('../../utils/client', async () => { + const actual = await vi.importActual('../../utils/client'); + return { + ...actual, + getClient: vi.fn(), + }; +}); + +describe('executeEndpointFeedback', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + setupTest(); + initializeConfig({ + apiKey: 'test-api-key', + apiUrl: 'https://api.firecrawl.dev', + }); + + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + afterEach(() => { + teardownTest(); + vi.clearAllMocks(); + }); + + it('posts generic endpoint feedback to /v2/feedback', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + success: true, + feedbackId: '0193f6c5-1234-7890-abcd-1234567890ab', + creditsRefunded: 1, + }), + }); + + const result = await executeEndpointFeedback({ + endpoint: 'scrape', + jobId: '0193f6c5-1234-7890-abcd-1234567890ab', + rating: 'partial', + issues: ['missing_markdown'], + tags: ['docs'], + note: 'The markdown missed the pricing table.', + url: 'https://example.com/pricing', + pageNumbers: [1, 2], + metadata: { source: 'test' }, + apiUrl: 'http://localhost:3002', + }); + + expect(getClient).toHaveBeenCalledWith({ + apiKey: undefined, + apiUrl: 'http://localhost:3002', + }); + expect(result).toEqual({ + success: true, + feedbackId: '0193f6c5-1234-7890-abcd-1234567890ab', + creditsRefunded: 1, + creditsRefundedToday: undefined, + dailyRefundCap: undefined, + dailyCapReached: false, + alreadySubmitted: undefined, + warning: undefined, + }); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3002/v2/feedback', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer test-api-key', + 'Content-Type': 'application/json', + }), + }) + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body).toEqual({ + endpoint: 'scrape', + jobId: '0193f6c5-1234-7890-abcd-1234567890ab', + rating: 'partial', + origin: 'cli', + integration: 'cli', + issues: ['missing_markdown'], + tags: ['docs'], + note: 'The markdown missed the pricing table.', + url: 'https://example.com/pricing', + pageNumbers: [1, 2], + metadata: { source: 'test' }, + }); + }); + + it('treats team opt-out as a disabled success', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({ + success: false, + error: 'Feedback is disabled for this team.', + feedbackErrorCode: 'TEAM_OPTED_OUT', + }), + }); + + await expect( + executeEndpointFeedback({ + endpoint: 'map', + jobId: '0193f6c5-1234-7890-abcd-1234567890ab', + rating: 'bad', + issues: ['missing_links'], + }) + ).resolves.toMatchObject({ + success: true, + disabled: true, + disabledSource: 'team', + creditsRefunded: 0, + }); + }); +}); + +describe('feedback parsing', () => { + it('parses endpoint names', () => { + expect(parseEndpointFeedbackEndpoint('Scrape')).toBe('scrape'); + expect(() => parseEndpointFeedbackEndpoint('crawl')).toThrow( + 'endpoint must be one of' + ); + }); + + it('parses comma-separated and JSON list options', () => { + expect( + parseFeedbackListArg('missing_markdown, bad_pdf', '--issues') + ).toEqual(['missing_markdown', 'bad_pdf']); + expect(parseFeedbackListArg('["a","b"]', '--tags')).toEqual(['a', 'b']); + }); + + it('parses positive page numbers', () => { + expect(parsePageNumbersArg('1, 2, bad, -1, 3')).toEqual([1, 2, 3]); + expect(parsePageNumbersArg('[4,5]')).toEqual([4, 5]); + }); +}); diff --git a/src/commands/feedback.ts b/src/commands/feedback.ts new file mode 100644 index 0000000000..cf239b6e99 --- /dev/null +++ b/src/commands/feedback.ts @@ -0,0 +1,407 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname } from 'path'; +import { getConfig, isCustomApiUrl, validateConfig } from '../utils/config'; +import { getClient } from '../utils/client'; +import { + parseMissingContentArg, + parseValuableSourcesArg, + type MissingContentInput, + type SearchFeedbackRating, + type ValuableSourceInput, +} from './search-feedback'; + +export type EndpointFeedbackEndpoint = 'search' | 'scrape' | 'parse' | 'map'; + +export interface EndpointFeedbackOptions { + endpoint: EndpointFeedbackEndpoint; + jobId: string; + rating: SearchFeedbackRating; + issues?: string[]; + tags?: string[]; + note?: string; + valuableSources?: ValuableSourceInput[]; + missingContent?: MissingContentInput[]; + querySuggestions?: string; + url?: string; + pageNumbers?: number[]; + metadata?: Record; + apiKey?: string; + apiUrl?: string; + output?: string; + json?: boolean; + pretty?: boolean; + silent?: boolean; +} + +export type EndpointFeedbackErrorCode = + | 'JOB_NOT_FOUND' + | 'SEARCH_NOT_FOUND' + | 'FEEDBACK_WINDOW_EXPIRED' + | 'SEARCH_FAILED' + | 'PREVIEW_TEAM_NOT_ALLOWED' + | 'TEAM_OPTED_OUT' + | 'INVALID_BODY' + | 'DB_DISABLED' + | 'INTERNAL'; + +export interface EndpointFeedbackResult { + success: boolean; + feedbackId?: string; + creditsRefunded?: number; + creditsRefundedToday?: number; + dailyRefundCap?: number; + dailyCapReached?: boolean; + alreadySubmitted?: boolean; + warning?: string; + error?: string; + errorCode?: EndpointFeedbackErrorCode; + status?: number; + disabled?: boolean; + disabledSource?: 'team'; +} + +const DEFAULT_API_URL = 'https://api.firecrawl.dev'; + +export const ENDPOINT_FEEDBACK_ENDPOINTS: EndpointFeedbackEndpoint[] = [ + 'search', + 'scrape', + 'parse', + 'map', +]; + +function normalizeList(entries: string[] | undefined): string[] | undefined { + const cleaned = entries + ?.map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return cleaned && cleaned.length > 0 ? cleaned : undefined; +} + +export function parseFeedbackListArg( + raw: string | undefined, + label: string +): string[] | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (!Array.isArray(parsed)) { + throw new Error(`${label} must be a JSON array.`); + } + return normalizeList( + parsed + .filter((entry) => typeof entry === 'string') + .map((entry) => entry as string) + ); + } catch (error: any) { + throw new Error( + `${label} must be a comma-separated list or valid JSON array.` + ); + } + } + + return normalizeList(trimmed.split(',')); +} + +export function parsePageNumbersArg( + raw: string | undefined +): number[] | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + + let values: unknown[]; + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (!Array.isArray(parsed)) { + throw new Error('--page-numbers must be a JSON array.'); + } + values = parsed; + } catch { + throw new Error( + '--page-numbers must be a comma-separated list or valid JSON array.' + ); + } + } else { + values = trimmed.split(',').map((entry) => entry.trim()); + } + + const numbers = values + .map((value) => + typeof value === 'number' ? value : Number.parseInt(String(value), 10) + ) + .filter((value) => Number.isInteger(value) && value > 0); + + return numbers.length > 0 ? numbers : undefined; +} + +export function parseMetadataArg( + raw: string | undefined, + filePath: string | undefined +): Record | undefined { + if (raw === undefined && filePath === undefined) return undefined; + + const input = + raw !== undefined ? raw : readFileSync(filePath as string, 'utf-8'); + + try { + const parsed = JSON.parse(input); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('metadata must be a JSON object.'); + } + return parsed as Record; + } catch (error: any) { + throw new Error(error?.message || 'Invalid metadata JSON.'); + } +} + +export function parseEndpointFeedbackEndpoint( + value: string +): EndpointFeedbackEndpoint { + const endpoint = value.toLowerCase(); + if ( + !ENDPOINT_FEEDBACK_ENDPOINTS.includes(endpoint as EndpointFeedbackEndpoint) + ) { + throw new Error( + `endpoint must be one of: ${ENDPOINT_FEEDBACK_ENDPOINTS.join(', ')}` + ); + } + return endpoint as EndpointFeedbackEndpoint; +} + +export function parseEndpointFeedbackRating( + value: string +): SearchFeedbackRating { + const rating = value.toLowerCase(); + if (!['good', 'bad', 'partial'].includes(rating)) { + throw new Error('--rating must be one of: good, bad, partial'); + } + return rating as SearchFeedbackRating; +} + +export function parseEndpointFeedbackCliOptions(options: { + issues?: string; + tags?: string; + pageNumbers?: string; + metadata?: string; + metadataFile?: string; + valuableSources?: string; + missingContent?: string | string[]; + rating?: string; +}) { + return { + rating: parseEndpointFeedbackRating(String(options.rating || '')), + issues: parseFeedbackListArg(options.issues, '--issues'), + tags: parseFeedbackListArg(options.tags, '--tags'), + pageNumbers: parsePageNumbersArg(options.pageNumbers), + metadata: parseMetadataArg(options.metadata, options.metadataFile), + valuableSources: parseValuableSourcesArg(options.valuableSources), + missingContent: parseMissingContentArg(options.missingContent), + }; +} + +export async function executeEndpointFeedback( + options: EndpointFeedbackOptions +): Promise { + try { + if (options.apiKey || options.apiUrl) { + getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); + } + + const config = getConfig(); + const apiKey = options.apiKey || config.apiKey; + const apiUrl = (options.apiUrl || config.apiUrl || DEFAULT_API_URL).replace( + /\/$/, + '' + ); + if (!isCustomApiUrl(apiUrl)) { + validateConfig(apiKey); + } + + const body: Record = { + endpoint: options.endpoint, + jobId: options.jobId, + rating: options.rating, + origin: 'cli', + integration: 'cli', + }; + + const entries: Array<[string, unknown]> = [ + ['issues', normalizeList(options.issues)], + ['tags', normalizeList(options.tags)], + ['note', options.note], + ['valuableSources', options.valuableSources], + ['missingContent', options.missingContent], + ['querySuggestions', options.querySuggestions], + ['url', options.url], + ['pageNumbers', options.pageNumbers], + ['metadata', options.metadata], + ]; + + for (const [key, value] of entries) { + if (value === undefined) continue; + if (Array.isArray(value) && value.length === 0) continue; + if (typeof value === 'string' && value.trim().length === 0) continue; + body[key] = value; + } + + const response = await fetch(`${apiUrl}/v2/feedback`, { + method: 'POST', + headers: { + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + const data: Record = await response + .json() + .catch(() => ({}) as Record); + + if (!response.ok || data?.success !== true) { + const errorMessage = + (typeof data?.error === 'string' && data.error) || + `HTTP ${response.status}: ${response.statusText}`; + const errorCode = + typeof data?.feedbackErrorCode === 'string' + ? (data.feedbackErrorCode as EndpointFeedbackErrorCode) + : undefined; + + if (errorCode === 'TEAM_OPTED_OUT') { + return { + success: true, + disabled: true, + disabledSource: 'team', + creditsRefunded: 0, + warning: + 'Feedback is disabled for this team. Contact support to re-enable.', + }; + } + + return { + success: false, + error: errorMessage, + errorCode, + status: response.status, + }; + } + + return { + success: true, + feedbackId: data.feedbackId, + creditsRefunded: data.creditsRefunded ?? 0, + creditsRefundedToday: + typeof data.creditsRefundedToday === 'number' + ? data.creditsRefundedToday + : undefined, + dailyRefundCap: + typeof data.dailyRefundCap === 'number' + ? data.dailyRefundCap + : undefined, + dailyCapReached: data.dailyCapReached === true, + alreadySubmitted: data.alreadySubmitted, + warning: data.warning, + }; + } catch (error: any) { + return { + success: false, + error: error?.message || 'Unknown error occurred', + }; + } +} + +function formatReadable(result: EndpointFeedbackResult): string { + const lines: string[] = []; + if (result.alreadySubmitted) { + lines.push('Feedback already submitted for this job.'); + } else { + lines.push('Feedback recorded.'); + } + if (result.feedbackId) { + lines.push(`Feedback ID: ${result.feedbackId}`); + } + lines.push(`Credits refunded: ${result.creditsRefunded ?? 0}`); + if ( + typeof result.creditsRefundedToday === 'number' && + typeof result.dailyRefundCap === 'number' + ) { + lines.push( + `Refunds today: ${result.creditsRefundedToday} / ${result.dailyRefundCap}` + ); + } + if (result.dailyCapReached) { + lines.push( + 'Daily refund cap reached; further feedback calls today will not refund credits.' + ); + } + if (result.warning) { + lines.push(`Warning: ${result.warning}`); + } + return lines.join('\n') + '\n'; +} + +export async function handleEndpointFeedbackCommand( + options: EndpointFeedbackOptions +): Promise { + const result = await executeEndpointFeedback(options); + + if (result.disabled) { + if (!options.silent) { + console.error(result.warning ?? 'Feedback is disabled for this team.'); + } + process.exit(0); + } + + if (!result.success) { + if (options.silent) { + process.exit(0); + } + console.error('Error:', result.error); + if (result.errorCode) { + console.error(`Code: ${result.errorCode}`); + } + process.exit(1); + } + + if (options.silent) { + return; + } + + let outputContent: string; + if (options.json || options.pretty) { + const json: Record = { + success: true, + feedbackId: result.feedbackId, + creditsRefunded: result.creditsRefunded ?? 0, + ...(typeof result.creditsRefundedToday === 'number' + ? { creditsRefundedToday: result.creditsRefundedToday } + : {}), + ...(typeof result.dailyRefundCap === 'number' + ? { dailyRefundCap: result.dailyRefundCap } + : {}), + ...(result.dailyCapReached ? { dailyCapReached: true } : {}), + ...(result.alreadySubmitted ? { alreadySubmitted: true } : {}), + ...(result.warning ? { warning: result.warning } : {}), + }; + outputContent = options.pretty + ? JSON.stringify(json, null, 2) + : JSON.stringify(json); + } else { + outputContent = formatReadable(result); + } + + if (options.output) { + const dir = dirname(options.output); + if (dir && !existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(options.output, outputContent, 'utf-8'); + console.error(`Output written to: ${options.output}`); + } else { + if (!outputContent.endsWith('\n')) outputContent += '\n'; + process.stdout.write(outputContent); + } +} diff --git a/src/index.ts b/src/index.ts index 44801d7339..887cb371dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,11 @@ import { parseMissingContentArg, type SearchFeedbackRating, } from './commands/search-feedback'; +import { + handleEndpointFeedbackCommand, + parseEndpointFeedbackCliOptions, + parseEndpointFeedbackEndpoint, +} from './commands/feedback'; import { handleAgentCommand } from './commands/agent'; import { handleBrowserLaunch, @@ -69,6 +74,7 @@ const AUTH_REQUIRED_COMMANDS = [ 'map', 'parse', 'search', + 'feedback', 'search-feedback', 'agent', 'browser', @@ -1087,6 +1093,100 @@ function createSearchFeedbackCommand(): Command { return cmd; } +/** + * Create the generic feedback command for v2 endpoint jobs. + */ +function createFeedbackCommand(): Command { + const cmd = new Command('feedback') + .description('Send feedback on a Firecrawl endpoint job.') + .argument('', 'Endpoint: search | scrape | parse | map') + .argument('', 'The job id returned by the endpoint') + .requiredOption('--rating ', 'Overall rating: good | bad | partial') + .option( + '--issues ', + 'Comma-separated issue codes OR JSON array of issue codes' + ) + .option( + '--tags ', + 'Comma-separated tags OR JSON array of tags' + ) + .option('--note ', 'Short note describing the feedback') + .option( + '--valuable-sources ', + 'Comma-separated URLs OR JSON array of {url, reason} entries' + ) + .option( + '--missing-content ', + 'Specific pieces of content missing from results. ' + + 'Accepts: JSON array of {topic, description} objects, ' + + 'comma-separated topics, or "topic: description" form.' + ) + .option( + '--query-suggestions ', + 'How the query or result set could be improved' + ) + .option('--url ', 'Relevant URL for scrape/parse feedback') + .option( + '--page-numbers ', + 'Comma-separated positive page numbers OR JSON array of numbers' + ) + .option('--metadata ', 'Additional small JSON object metadata') + .option('--metadata-file ', 'Path to metadata JSON object') + .option( + '-k, --api-key ', + 'Firecrawl API key (overrides global --api-key)' + ) + .option('--api-url ', 'API URL (overrides global --api-url)') + .option('-o, --output ', 'Output file path (default: stdout)') + .option('--json', 'Output as compact JSON', false) + .option('--pretty', 'Pretty print JSON output', false) + .option( + '--silent', + 'Suppress output; useful when called in the background by another agent', + false + ) + .action(async (endpointArg: string, jobId: string, options: any) => { + let endpoint; + try { + endpoint = parseEndpointFeedbackEndpoint(endpointArg); + } catch (error: any) { + console.error('Error:', error?.message || 'Invalid endpoint'); + process.exit(1); + } + + let parsed; + try { + parsed = parseEndpointFeedbackCliOptions(options); + } catch (error: any) { + console.error('Error:', error?.message || 'Invalid feedback options'); + process.exit(1); + } + + await handleEndpointFeedbackCommand({ + endpoint, + jobId, + rating: parsed.rating, + issues: parsed.issues, + tags: parsed.tags, + note: options.note, + valuableSources: parsed.valuableSources, + missingContent: parsed.missingContent, + querySuggestions: options.querySuggestions, + url: options.url, + pageNumbers: parsed.pageNumbers, + metadata: parsed.metadata, + apiKey: options.apiKey, + apiUrl: options.apiUrl, + output: options.output, + json: options.json, + pretty: options.pretty, + silent: options.silent, + }); + }); + + return cmd; +} + /** * Create and configure the agent command */ @@ -1668,6 +1768,7 @@ program.addCommand(createMapCommand()); program.addCommand(createParseCommand()); program.addCommand(createMonitorCommand()); program.addCommand(createSearchCommand()); +program.addCommand(createFeedbackCommand()); program.addCommand(createSearchFeedbackCommand()); program.addCommand(createAgentCommand()); program.addCommand(createInteractCommand()); From 7f4ac31bac9a182aa1a6a988516dbcf3fe8feaea Mon Sep 17 00:00:00 2001 From: Abimael Martell <1450169+abimaelmartell@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:00:20 -0700 Subject: [PATCH 2/6] chore: bump version to 1.19.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c529441788..869ce32a46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.19.6", + "version": "1.19.7", "description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.", "main": "dist/index.js", "bin": { From dbfdfe79bc67cb0362adf87ce1fa7edeb85224b8 Mon Sep 17 00:00:00 2001 From: Abimael Martell <1450169+abimaelmartell@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:09:07 -0700 Subject: [PATCH 3/6] chore: bump version to 1.19.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 869ce32a46..88038fa8b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.19.7", + "version": "1.19.8", "description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.", "main": "dist/index.js", "bin": { From 51c728c6d417134c51ecc250538a94671f84b5bf Mon Sep 17 00:00:00 2001 From: Abimael Martell <1450169+abimaelmartell@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:14:47 -0700 Subject: [PATCH 4/6] feat(cli): add endpoint feedback opt-out --- README.md | 3 ++ skills/firecrawl-cli/SKILL.md | 2 + src/__tests__/commands/feedback.test.ts | 58 +++++++++++++++++++++++++ src/commands/feedback.ts | 37 ++++++++++++++-- 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 56979bf6f4..44e9d030e1 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,9 @@ firecrawl feedback scrape 0193f6c5-1234-7890-abcd-1234567890ab \ Keep notes and metadata small. Do not send raw scrape or parse outputs as feedback. +Set `FIRECRAWL_NO_ENDPOINT_FEEDBACK=1` to make `firecrawl feedback` skip +endpoint feedback calls silently. + #### Feedback Options | Option | Description | diff --git a/skills/firecrawl-cli/SKILL.md b/skills/firecrawl-cli/SKILL.md index ea9f925f25..70472cb004 100644 --- a/skills/firecrawl-cli/SKILL.md +++ b/skills/firecrawl-cli/SKILL.md @@ -296,6 +296,8 @@ firecrawl feedback scrape "$SCRAPE_ID" \ Keep generic feedback small: issue codes, tags, short notes, URLs, page numbers, and small metadata objects. Do not send raw scrape/parse outputs or full page contents as feedback. +**Opt out:** `export FIRECRAWL_NO_ENDPOINT_FEEDBACK=1` makes the CLI skip every endpoint feedback call silently. Respect that flag — do not try to work around it. + ## Parallelization Run independent operations in parallel. Check `firecrawl --status` for concurrency limit: diff --git a/src/__tests__/commands/feedback.test.ts b/src/__tests__/commands/feedback.test.ts index f3feddcde6..cbb906ace7 100644 --- a/src/__tests__/commands/feedback.test.ts +++ b/src/__tests__/commands/feedback.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { executeEndpointFeedback, + handleEndpointFeedbackCommand, parseEndpointFeedbackEndpoint, parseFeedbackListArg, parsePageNumbersArg, @@ -34,6 +35,8 @@ describe('executeEndpointFeedback', () => { afterEach(() => { teardownTest(); vi.clearAllMocks(); + delete process.env.FIRECRAWL_NO_ENDPOINT_FEEDBACK; + delete process.env.FIRECRAWL_DISABLE_ENDPOINT_FEEDBACK; }); it('posts generic endpoint feedback to /v2/feedback', async () => { @@ -128,6 +131,61 @@ describe('executeEndpointFeedback', () => { creditsRefunded: 0, }); }); + + it('skips endpoint feedback when local opt-out is set', async () => { + process.env.FIRECRAWL_NO_ENDPOINT_FEEDBACK = '1'; + + await expect( + executeEndpointFeedback({ + endpoint: 'scrape', + jobId: '0193f6c5-1234-7890-abcd-1234567890ab', + rating: 'bad', + issues: ['missing_markdown'], + }) + ).resolves.toMatchObject({ + success: true, + disabled: true, + disabledSource: 'env', + creditsRefunded: 0, + }); + + expect(getClient).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('handles local opt-out silently in the CLI command path', async () => { + process.env.FIRECRAWL_NO_ENDPOINT_FEEDBACK = '1'; + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((( + code?: number | string | null + ) => { + throw new Error(`process.exit:${code}`); + }) as typeof process.exit); + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const stdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + try { + await expect( + handleEndpointFeedbackCommand({ + endpoint: 'scrape', + jobId: '0193f6c5-1234-7890-abcd-1234567890ab', + rating: 'bad', + issues: ['missing_markdown'], + }) + ).rejects.toThrow('process.exit:0'); + + expect(stderrSpy).not.toHaveBeenCalled(); + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(getClient).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + stdoutSpy.mockRestore(); + } + }); }); describe('feedback parsing', () => { diff --git a/src/commands/feedback.ts b/src/commands/feedback.ts index cf239b6e99..14318a8c9e 100644 --- a/src/commands/feedback.ts +++ b/src/commands/feedback.ts @@ -57,9 +57,15 @@ export interface EndpointFeedbackResult { errorCode?: EndpointFeedbackErrorCode; status?: number; disabled?: boolean; - disabledSource?: 'team'; + disabledSource?: 'env' | 'team'; } +export const ENDPOINT_FEEDBACK_OPT_OUT_ENV_VARS = [ + 'FIRECRAWL_NO_ENDPOINT_FEEDBACK', + 'FIRECRAWL_DISABLE_ENDPOINT_FEEDBACK', +] as const; + +const TRUTHY = new Set(['1', 'true', 'yes', 'on']); const DEFAULT_API_URL = 'https://api.firecrawl.dev'; export const ENDPOINT_FEEDBACK_ENDPOINTS: EndpointFeedbackEndpoint[] = [ @@ -182,6 +188,18 @@ export function parseEndpointFeedbackRating( return rating as SearchFeedbackRating; } +export function isEndpointFeedbackDisabledLocally( + env: NodeJS.ProcessEnv = process.env +): boolean { + for (const key of ENDPOINT_FEEDBACK_OPT_OUT_ENV_VARS) { + const value = env[key]; + if (typeof value === 'string' && TRUTHY.has(value.trim().toLowerCase())) { + return true; + } + } + return false; +} + export function parseEndpointFeedbackCliOptions(options: { issues?: string; tags?: string; @@ -206,6 +224,15 @@ export function parseEndpointFeedbackCliOptions(options: { export async function executeEndpointFeedback( options: EndpointFeedbackOptions ): Promise { + if (isEndpointFeedbackDisabledLocally()) { + return { + success: true, + disabled: true, + disabledSource: 'env', + creditsRefunded: 0, + }; + } + try { if (options.apiKey || options.apiUrl) { getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); @@ -349,9 +376,13 @@ export async function handleEndpointFeedbackCommand( const result = await executeEndpointFeedback(options); if (result.disabled) { - if (!options.silent) { - console.error(result.warning ?? 'Feedback is disabled for this team.'); + if (result.disabledSource === 'env') { + process.exit(0); + } + if (options.silent) { + process.exit(0); } + console.error(result.warning ?? 'Feedback is disabled for this team.'); process.exit(0); } From 277438f26e5f66b2c6322bda93cee7dda08f4054 Mon Sep 17 00:00:00 2001 From: Abimael Martell <1450169+abimaelmartell@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:22:48 -0700 Subject: [PATCH 5/6] chore: bump version to 1.19.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 88038fa8b2..ed86f4e63c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.19.8", + "version": "1.19.9", "description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.", "main": "dist/index.js", "bin": { From 47c75573562999b405d75f9e6dda6142bc8ab6cc Mon Sep 17 00:00:00 2001 From: Abimael Martell <1450169+abimaelmartell@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:28:35 -0700 Subject: [PATCH 6/6] chore: bump version to 1.19.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ed86f4e63c..bdc36b3454 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.19.9", + "version": "1.19.10", "description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.", "main": "dist/index.js", "bin": {