Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
fe52185
fix(setup, init): skip unavailable clients in --agent all and only of…
erikengervall Oct 18, 2026
cb9a041
fix(setup): require an executable for client detection; tighten templ…
erikengervall Oct 18, 2026
8b4e2b7
feat(cli): let wrappers set the integration tag with FIRECRAWL_INTEGR…
erikengervall Oct 19, 2026
f69759c
test: cover the integration tag in feedback requests and narrow the R…
erikengervall Oct 19, 2026
77c4fa4
chore(deps): bump firecrawl SDK to 4.49.1
erikengervall Oct 19, 2026
e6d7427
Merge remote-tracking branch 'origin/main' into capy/skip-unavailable…
erikengervall Oct 19, 2026
f26abb7
chore: bump version to 1.26.11
erikengervall Oct 19, 2026
ecd89bb
Merge remote-tracking branch 'origin/capy/skip-unavailable-clients-in…
erikengervall Oct 19, 2026
0c4e55a
chore: bump version to 1.26.12
erikengervall Oct 19, 2026
017f527
Merge branch 'capy/bump-firecrawl-sdk-4-49-1' into capy/firecrawl-int…
erikengervall Oct 19, 2026
e34d00f
fix(setup): keep quiet setup mcp output compact; name the failing-cli…
erikengervall Oct 19, 2026
4902305
Merge remote-tracking branch 'origin/capy/skip-unavailable-clients-in…
erikengervall Oct 19, 2026
747caf9
Merge branch 'capy/bump-firecrawl-sdk-4-49-1' into capy/firecrawl-int…
erikengervall Oct 19, 2026
3c12476
fix(setup): treat empty PATH entries as the current directory; docume…
erikengervall Oct 19, 2026
d1d7e25
Merge remote-tracking branch 'origin/capy/skip-unavailable-clients-in…
erikengervall Oct 19, 2026
2a4a9aa
Merge branch 'capy/bump-firecrawl-sdk-4-49-1' into capy/firecrawl-int…
erikengervall Oct 19, 2026
ffdc439
fix(setup): ignore an unset PATH in client detection; name --agent in…
erikengervall Oct 19, 2026
cff0387
Merge remote-tracking branch 'origin/capy/skip-unavailable-clients-in…
erikengervall Oct 19, 2026
0af7559
Merge branch 'capy/bump-firecrawl-sdk-4-49-1' into capy/firecrawl-int…
erikengervall Oct 19, 2026
be477ff
fix(setup): normalize Cursor, Gemini CLI, Windsurf and Antigravity ag…
erikengervall Oct 19, 2026
3b256ed
Merge remote-tracking branch 'origin/capy/skip-unavailable-clients-in…
erikengervall Oct 19, 2026
e82692f
Merge branch 'capy/bump-firecrawl-sdk-4-49-1' into capy/firecrawl-int…
erikengervall Oct 19, 2026
242752b
chore(deps): bump firecrawl SDK to 4.50.0
erikengervall Oct 19, 2026
be748c3
Merge branch 'capy/bump-firecrawl-sdk-4-49-1' into capy/firecrawl-int…
erikengervall Oct 19, 2026
2c2447e
docs(readme): list search-feedback among the integration-tagged requests
erikengervall Oct 19, 2026
35e5a99
Merge remote-tracking branch 'origin/main' into capy/bump-firecrawl-s…
erikengervall Oct 20, 2026
e824ae9
Merge branch 'capy/bump-firecrawl-sdk-4-49-1' into capy/firecrawl-int…
erikengervall Oct 20, 2026
77fe095
Merge remote-tracking branch 'origin/main' into capy/firecrawl-integr…
erikengervall Oct 20, 2026
3910162
feat(cli): send FIRECRAWL_INTEGRATION as the X-Integration header on …
erikengervall Oct 20, 2026
e9251a3
test(doctor): assert X-Integration on doctor requests; fix the README…
erikengervall Oct 20, 2026
ada5fde
docs(readme): say a value that cannot be a header is not sent
erikengervall Oct 20, 2026
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1063,6 +1063,12 @@ 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 (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
```

---

## Experimental
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "firecrawl-cli",
"version": "1.26.14",
"version": "1.26.15",
"publishConfig": {
"tag": "latest"
},
Expand Down
40 changes: 40 additions & 0 deletions src/__tests__/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down Expand Up @@ -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 });

Expand Down
39 changes: 39 additions & 0 deletions src/__tests__/commands/feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
parsePageNumbersArg,
} from '../../commands/feedback';
import {
executeSearchFeedback,
parseValuableResultsArg,
type ValuableResultInput,
} from '../../commands/search-feedback';
Expand Down Expand Up @@ -159,6 +160,44 @@ describe('executeEndpointFeedback', () => {
});
});

it('sends FIRECRAWL_INTEGRATION as X-Integration on 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(endpointRequest.headers['X-Integration']).toBe('rtk');
expect(JSON.parse(endpointRequest.body)).toMatchObject({
origin: 'cli',
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: 'cli',
});
});

it('rejects --valuable-results on non-search endpoints', async () => {
const result = await executeEndpointFeedback({
endpoint: 'scrape',
Expand Down
110 changes: 110 additions & 0 deletions src/__tests__/utils/integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Tests for the X-Integration header
*/

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('integrationHeaders', () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it('sends nothing when FIRECRAWL_INTEGRATION is unset or blank', () => {
vi.stubEnv('FIRECRAWL_INTEGRATION', undefined);
expect(integrationHeaders()).toEqual({});
vi.stubEnv('FIRECRAWL_INTEGRATION', ' ');
expect(integrationHeaders()).toEqual({});
});

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', ' hermes\n');
expect(integrationHeaders()).toEqual({ 'X-Integration': 'hermes' });
});

it.each(['bad\nvalue', 'tag\u2713'])(
'drops %j, which cannot be sent as a header',
(value) => {
vi.stubEnv('FIRECRAWL_INTEGRATION', value);
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<void>((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();
});
});
2 changes: 2 additions & 0 deletions src/commands/credit-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +59,7 @@ export async function executeCreditUsage(
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...integrationHeaders(),
},
});

Expand Down
4 changes: 4 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { isJobId } from '../utils/job';
import { detectAgents, type AgentDetection } from '../utils/agents';
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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';
Expand Down Expand Up @@ -121,6 +122,7 @@ async function pingCreditUsage(
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...integrationHeaders(),
},
});
const durationMs = Date.now() - start;
Expand Down Expand Up @@ -156,6 +158,7 @@ async function fetchQueueStatus(
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...integrationHeaders(),
},
});
if (!response.ok) {
Expand Down Expand Up @@ -698,6 +701,7 @@ export async function runSupportAsk(options: DoctorOptions): Promise<number> {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...integrationHeaders(),
},
body: JSON.stringify({ question, jobId }),
});
Expand Down
2 changes: 2 additions & 0 deletions src/commands/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type ValuableResultInput,
type ValuableSourceInput,
} from './search-feedback';
import { integrationHeaders } from '../utils/integration';

export type EndpointFeedbackEndpoint = 'search' | 'scrape' | 'parse' | 'map';

Expand Down Expand Up @@ -313,6 +314,7 @@ export async function executeEndpointFeedback(
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
'Content-Type': 'application/json',
...integrationHeaders(),
},
body: JSON.stringify(body),
});
Expand Down
3 changes: 2 additions & 1 deletion src/commands/interact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
clearInteractSession,
} from '../utils/interact-session';
import { writeOutput } from '../utils/output';
import { integrationHeaders } from '../utils/integration';

export interface InteractExecuteOptions {
scrapeId?: string;
Expand Down Expand Up @@ -60,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() };
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
type ListOptions = AlexandriaOptions & {
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/commands/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -63,6 +64,7 @@ async function monitorRequest(
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
'X-Origin': 'cli',
...integrationHeaders(),
};
if (init.body !== undefined) headers['Content-Type'] = 'application/json';

Expand Down
12 changes: 6 additions & 6 deletions src/commands/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { integrationHeaders } from '../utils/integration';

const DEFAULT_API_URL = 'https://api.firecrawl.dev';

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

Expand Down
2 changes: 2 additions & 0 deletions src/commands/search-feedback.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getConfig, validateConfig } from '../utils/config';
import { getClient } from '../utils/client';
import { integrationHeaders } from '../utils/integration';

export type SearchFeedbackRating = 'good' | 'bad' | 'partial';

Expand Down Expand Up @@ -156,6 +157,7 @@ export async function executeSearchFeedback(
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...integrationHeaders(),
},
body: JSON.stringify(body),
});
Expand Down
Loading
Loading