diff --git a/README.md b/README.md index e2a468eb40..7d18a9cf62 100644 --- a/README.md +++ b/README.md @@ -1069,6 +1069,12 @@ Requests from the CLI are tagged `integration: cli`. A tool that wraps the CLI c export FIRECRAWL_INTEGRATION=_my-tool ``` +Supported API requests carry an origin of `cli@`. When a coding agent runs the CLI, the origin also names the agent, for example `cli.codex@`; it is detected from environment variables the agent sets in the commands it runs. Calls routed through the SDK include the agent only after the pinned SDK supports the origin option. To send plain `cli@` instead: + +```bash +export FIRECRAWL_CLI_DISABLE_AGENT_ORIGIN=1 +``` + --- ## Experimental diff --git a/src/__tests__/alexandria-beta.test.ts b/src/__tests__/alexandria-beta.test.ts index 81751cc077..1978bc209f 100644 --- a/src/__tests__/alexandria-beta.test.ts +++ b/src/__tests__/alexandria-beta.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest'; +import packageJson from '../../package.json'; const exec = promisify(execFile); const requests: { url?: string; headers: Record; body: any }[] = @@ -1868,28 +1869,32 @@ it('forwards URL scrape discovery detail and rejects invalid combinations locall it('submits Alexandria session feedback without a job ID', async () => { response = { success: true, feedbackId: 'feedback-1', creditsRefunded: 0 }; - const result = await cli([ - 'alexandria', - 'feedback', - '--rating', - 'partial', - '--url', - 'https://example.com', - '--requested-functionality', - 'Download attachments', - '--objective', - ' Compare contract requirements across agencies ', - '--rationale', - 'Only summaries available', - '--json', - ]); + const result = await cli( + [ + 'alexandria', + 'feedback', + '--rating', + 'partial', + '--url', + 'https://example.com', + '--requested-functionality', + 'Download attachments', + '--objective', + ' Compare contract requirements across agencies ', + '--rationale', + 'Only summaries available', + '--json', + ], + 'fc-test', + { FIRECRAWL_CLI_DISABLE_AGENT_ORIGIN: '1' } + ); expect(result.code).toBe(0); expect(JSON.parse(result.stdout).feedbackId).toBe('feedback-1'); expect(requests[0].url).toBe('/v2/feedback'); expect(requests[0].body).toEqual({ endpoint: 'alexandria', rating: 'partial', - origin: 'cli', + origin: `cli@${packageJson.version}`, integration: 'cli', requestedWebsite: { url: 'https://example.com', diff --git a/src/__tests__/commands/developer.test.ts b/src/__tests__/commands/developer.test.ts index b41dbb1d4e..5a870b5815 100644 --- a/src/__tests__/commands/developer.test.ts +++ b/src/__tests__/commands/developer.test.ts @@ -9,6 +9,10 @@ import { initializeConfig } from '../../utils/config'; import { writeOutput } from '../../utils/output'; import { setupTest, teardownTest } from '../utils/mock-client'; +vi.mock('../../utils/agent-origin', () => ({ + getCliOrigin: () => 'cli.test@1.0.0', +})); + vi.mock('../../utils/output', () => ({ writeOutput: vi.fn() })); vi.mock('../../utils/client', async () => { @@ -67,7 +71,7 @@ describe('handleDeveloperSearchCommand', () => { expect(mockHttpGet).toHaveBeenCalledTimes(1); expect(mockHttpGet).toHaveBeenCalledWith( - '/v2/search/developer?query=tokio+spawn_blocking&integration=cli' + '/v2/search/developer?query=tokio+spawn_blocking&integration=cli&origin=cli.test%401.0.0' ); }); @@ -80,7 +84,7 @@ describe('handleDeveloperSearchCommand', () => { }); expect(mockHttpGet).toHaveBeenCalledWith( - '/v2/search/developer?query=tokio+spawn_blocking&k=5&integration=cli' + '/v2/search/developer?query=tokio+spawn_blocking&k=5&integration=cli&origin=cli.test%401.0.0' ); }); diff --git a/src/__tests__/commands/feedback.test.ts b/src/__tests__/commands/feedback.test.ts index 2e523b6484..106ad01731 100644 --- a/src/__tests__/commands/feedback.test.ts +++ b/src/__tests__/commands/feedback.test.ts @@ -16,6 +16,10 @@ import { getClient } from '../../utils/client'; import { initializeConfig } from '../../utils/config'; import { setupTest, teardownTest } from '../utils/mock-client'; +vi.mock('../../utils/agent-origin', () => ({ + getCliOrigin: () => 'cli.test@1.0.0', +})); + vi.mock('../../utils/client', async () => { const actual = await vi.importActual('../../utils/client'); return { @@ -85,7 +89,7 @@ describe('executeEndpointFeedback', () => { expect(JSON.parse(request.body)).toEqual({ endpoint: 'alexandria', rating: 'partial', - origin: 'cli', + origin: 'cli.test@1.0.0', integration: 'cli', requestedWebsite, rationale: 'Only summaries available', @@ -149,7 +153,7 @@ describe('executeEndpointFeedback', () => { endpoint: 'scrape', jobId: '0193f6c5-1234-7890-abcd-1234567890ab', rating: 'partial', - origin: 'cli', + origin: 'cli.test@1.0.0', integration: 'cli', issues: ['missing_markdown'], tags: ['docs'], @@ -183,7 +187,7 @@ describe('executeEndpointFeedback', () => { expect(endpointUrl).toBe('https://api.firecrawl.dev/v2/feedback'); expect(endpointRequest.headers['X-Integration']).toBe('rtk'); expect(JSON.parse(endpointRequest.body)).toMatchObject({ - origin: 'cli', + origin: 'cli.test@1.0.0', integration: 'cli', }); const [searchUrl, searchRequest] = mockFetch.mock.calls[1]; @@ -193,7 +197,7 @@ describe('executeEndpointFeedback', () => { expect(searchRequest.headers['X-Integration']).toBe('rtk'); expect(JSON.parse(searchRequest.body)).toEqual({ rating: 'good', - origin: 'cli', + origin: 'cli.test@1.0.0', integration: 'cli', }); }); diff --git a/src/__tests__/commands/gov.test.ts b/src/__tests__/commands/gov.test.ts index a371483911..4d099fb07d 100644 --- a/src/__tests__/commands/gov.test.ts +++ b/src/__tests__/commands/gov.test.ts @@ -9,6 +9,10 @@ import { initializeConfig } from '../../utils/config'; import { writeOutput } from '../../utils/output'; import { setupTest, teardownTest } from '../utils/mock-client'; +vi.mock('../../utils/agent-origin', () => ({ + getCliOrigin: () => 'cli.test@1.0.0', +})); + vi.mock('../../utils/output', () => ({ writeOutput: vi.fn() })); vi.mock('../../utils/client', async () => { @@ -56,8 +60,14 @@ describe('handleGovSearchCommand', () => { describe('API call generation', () => { it.each([ - [{}, '/v2/search/gov?query=food+labeling&integration=cli'], - [{ k: 5 }, '/v2/search/gov?query=food+labeling&k=5&integration=cli'], + [ + {}, + '/v2/search/gov?query=food+labeling&integration=cli&origin=cli.test%401.0.0', + ], + [ + { k: 5 }, + '/v2/search/gov?query=food+labeling&k=5&integration=cli&origin=cli.test%401.0.0', + ], ])('calls /v2/search/gov with %o', async (extra, expectedUrl) => { mockHttpGet.mockResolvedValue(mockGovResponse([sampleResult])); @@ -140,7 +150,7 @@ describe('handleGovSearchCommand', () => { expect(mockHttpGet).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledWith( - 'https://api.firecrawl.dev/v2/search/gov?query=food+labeling&integration=cli', + 'https://api.firecrawl.dev/v2/search/gov?query=food+labeling&integration=cli&origin=cli.test%401.0.0', expect.objectContaining({ method: 'GET' }) ); expect(vi.mocked(writeOutput).mock.calls[0][0]).toContain( diff --git a/src/__tests__/commands/interact.test.ts b/src/__tests__/commands/interact.test.ts new file mode 100644 index 0000000000..43855cc351 --- /dev/null +++ b/src/__tests__/commands/interact.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { handleInteractExecute } from '../../commands/interact'; +import { initializeConfig } from '../../utils/config'; +import { setupTest, teardownTest } from '../utils/mock-client'; + +vi.mock('../../utils/agent-origin', () => ({ + getCliOrigin: () => 'cli.test@1.0.0', +})); + +vi.mock('../../utils/output', () => ({ writeOutput: vi.fn() })); + +describe('handleInteractExecute', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + setupTest(); + initializeConfig({ + apiKey: 'test-api-key', + apiUrl: 'https://api.firecrawl.dev', + }); + mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, stdout: 'ok' }), + }); + vi.stubGlobal('fetch', mockFetch); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + teardownTest(); + }); + + it('sends the CLI origin and integration in the request body', async () => { + await handleInteractExecute({ + scrapeId: 'scrape-1', + code: 'console.log(1)', + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.firecrawl.dev/v2/scrape/scrape-1/interact'); + expect(JSON.parse(init.body as string)).toEqual({ + origin: 'cli.test@1.0.0', + integration: 'cli', + code: 'console.log(1)', + language: 'node', + }); + }); +}); diff --git a/src/__tests__/commands/parse.test.ts b/src/__tests__/commands/parse.test.ts index 119a51124c..73c6d6197a 100644 --- a/src/__tests__/commands/parse.test.ts +++ b/src/__tests__/commands/parse.test.ts @@ -10,6 +10,10 @@ import { executeParse } from '../../commands/parse'; import { initializeConfig } from '../../utils/config'; import { setupTest, teardownTest } from '../utils/mock-client'; +vi.mock('../../utils/agent-origin', () => ({ + getCliOrigin: () => 'cli.test@1.0.0', +})); + vi.mock('../../utils/credentials', () => ({ loadCredentials: vi.fn(() => null), })); @@ -68,6 +72,7 @@ describe('executeParse', () => { expect(options).toEqual({ formats: ['markdown'], integration: 'cli', + origin: 'cli.test@1.0.0', }); expect(init.body.get('file')).toBeInstanceOf(Blob); }); diff --git a/src/__tests__/utils/agent-origin.test.ts b/src/__tests__/utils/agent-origin.test.ts new file mode 100644 index 0000000000..549d9c774e --- /dev/null +++ b/src/__tests__/utils/agent-origin.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import packageJson from '../../../package.json'; +import { buildCliOrigin, detectAgent } from '../../utils/agent-origin'; + +const version = packageJson.version; + +describe('detectAgent', () => { + it.each([ + [{ CLAUDECODE: '1' }, 'claude-code'], + [{ CLAUDE_CODE_ENTRYPOINT: 'cli' }, 'claude-code'], + [{ CODEX_SANDBOX: 'seatbelt' }, 'codex'], + [{ CODEX_SANDBOX_NETWORK_DISABLED: '1' }, 'codex'], + [{ CODEX_THREAD_ID: 'abc' }, 'codex'], + [{ CODEX_MANAGED_BY_NPM: '1' }, 'codex'], + [{ CODEX_MANAGED_BY_BUN: '1' }, 'codex'], + [{ CODEX_MANAGED_BY_PNPM: '1' }, 'codex'], + [{ CODEX_MANAGED_BY_VITE_PLUS: '1' }, 'codex'], + [{ CURSOR_AGENT: '1' }, 'cursor'], + [{ GEMINI_CLI: '1' }, 'gemini-cli'], + [{ OPENCODE: '1' }, 'opencode'], + [{ GOOSE_TERMINAL: '1' }, 'goose'], + [{ CLINE_ACTIVE: 'true' }, 'cline'], + ])('maps %j to %s', (env, slug) => { + expect(detectAgent(env)).toBe(slug); + }); + + it('returns undefined without agent variables', () => { + expect(detectAgent({ PATH: '/usr/bin', TERM_PROGRAM: 'vscode' })).toBe( + undefined + ); + }); + + it('ignores empty and falsy values', () => { + expect(detectAgent({ CLAUDECODE: '', GEMINI_CLI: '0' })).toBe(undefined); + expect(detectAgent({ OPENCODE: 'false' })).toBe(undefined); + }); + + it('prefers the innermost agent when several are detected', () => { + expect(detectAgent({ CLAUDECODE: '1', OPENCODE: '1' })).toBe('opencode'); + }); + + it.each(['1', 'true', 'YES', 'on'])( + 'returns undefined when the opt-out is %s', + (value) => { + expect( + detectAgent({ + CLAUDECODE: '1', + FIRECRAWL_CLI_DISABLE_AGENT_ORIGIN: value, + }) + ).toBe(undefined); + } + ); + + it.each(['0', 'false', 'no', 'off', ''])( + 'keeps the agent when the opt-out is %j', + (value) => { + expect( + detectAgent({ + CLAUDECODE: '1', + FIRECRAWL_CLI_DISABLE_AGENT_ORIGIN: value, + }) + ).toBe('claude-code'); + } + ); +}); + +describe('buildCliOrigin', () => { + it('stamps the agent before the version', () => { + expect(buildCliOrigin({ CURSOR_AGENT: '1' })).toBe(`cli.cursor@${version}`); + }); + + it('falls back to plain cli@', () => { + expect(buildCliOrigin({})).toBe(`cli@${version}`); + expect( + buildCliOrigin({ + GEMINI_CLI: '1', + FIRECRAWL_CLI_DISABLE_AGENT_ORIGIN: '1', + }) + ).toBe(`cli@${version}`); + }); +}); + +describe('getCliOrigin', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('computes the origin once per process', async () => { + for (const name of [ + 'CLAUDECODE', + 'CLAUDE_CODE_ENTRYPOINT', + 'CODEX_SANDBOX', + 'CODEX_SANDBOX_NETWORK_DISABLED', + 'CODEX_THREAD_ID', + 'CODEX_MANAGED_BY_NPM', + 'CODEX_MANAGED_BY_BUN', + 'CODEX_MANAGED_BY_PNPM', + 'CODEX_MANAGED_BY_VITE_PLUS', + 'CURSOR_AGENT', + 'OPENCODE', + 'GOOSE_TERMINAL', + 'CLINE_ACTIVE', + 'FIRECRAWL_CLI_DISABLE_AGENT_ORIGIN', + ]) + vi.stubEnv(name, ''); + vi.stubEnv('GEMINI_CLI', '1'); + vi.resetModules(); + const { getCliOrigin } = await import('../../utils/agent-origin'); + expect(getCliOrigin()).toBe(`cli.gemini-cli@${version}`); + + vi.stubEnv('GEMINI_CLI', ''); + vi.stubEnv('OPENCODE', '1'); + expect(getCliOrigin()).toBe(`cli.gemini-cli@${version}`); + }); +}); diff --git a/src/__tests__/utils/client.test.ts b/src/__tests__/utils/client.test.ts index 8664d4546f..36a4afb296 100644 --- a/src/__tests__/utils/client.test.ts +++ b/src/__tests__/utils/client.test.ts @@ -10,6 +10,10 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { keylessGet, keylessRequest } from '../../utils/client'; +vi.mock('../../utils/agent-origin', () => ({ + getCliOrigin: () => 'cli.test@1.0.0', +})); + const OWN_SIGNUP_URL = 'https://firecrawl.dev/k/7fq2xab9'; const API_LIMIT_MESSAGE = `You've hit Firecrawl's keyless free tier rate limit. To continue now, create a free API key at ${OWN_SIGNUP_URL} @@ -76,4 +80,17 @@ describe('keyless requests', () => { expect(headers.Authorization).toBeUndefined(); } }); + + it('stamps the agent origin in the POST body without overriding integration', async () => { + const fetchMock = stubFetch(200, { success: true }); + + await keylessRequest('/v2/scrape', { + url: 'https://example.com', + integration: 'cli', + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body.origin).toBe('cli.test@1.0.0'); + expect(body.integration).toBe('cli'); + }); }); diff --git a/src/commands/developer.ts b/src/commands/developer.ts index 5d2f525e39..cbbc451a4b 100644 --- a/src/commands/developer.ts +++ b/src/commands/developer.ts @@ -1,4 +1,5 @@ import { getClient, isKeylessMode, keylessGet } from '../utils/client'; +import { getCliOrigin } from '../utils/agent-origin'; import { writeOutput } from '../utils/output'; import type { DeveloperItem, @@ -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=cli&origin=${encodeURIComponent(getCliOrigin())}`; 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 215626403d..dd7eb37fbd 100644 --- a/src/commands/feedback.ts +++ b/src/commands/feedback.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { dirname } from 'path'; import { getConfig, isCustomApiUrl, validateConfig } from '../utils/config'; import { getClient } from '../utils/client'; +import { getCliOrigin } from '../utils/agent-origin'; import { parseMissingContentArg, parseValuableResultsArg, @@ -264,7 +265,7 @@ export async function executeEndpointFeedback( endpoint: options.endpoint, ...(options.endpoint === 'alexandria' ? {} : { jobId: options.jobId }), rating: options.rating, - origin: 'cli', + origin: getCliOrigin(), integration: 'cli', }; diff --git a/src/commands/gov.ts b/src/commands/gov.ts index f92f679c2e..a93684d058 100644 --- a/src/commands/gov.ts +++ b/src/commands/gov.ts @@ -1,4 +1,5 @@ import { getClient, isKeylessMode, keylessGet } from '../utils/client'; +import { getCliOrigin } from '../utils/agent-origin'; import { writeOutput } from '../utils/output'; import type { GovResult, @@ -9,7 +10,7 @@ import type { 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=cli&origin=${encodeURIComponent(getCliOrigin())}`; 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 c42b810b12..19c3e8b358 100644 --- a/src/commands/interact.ts +++ b/src/commands/interact.ts @@ -4,6 +4,7 @@ */ import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client'; +import { getCliOrigin } from '../utils/agent-origin'; import { getConfig, validateConfig } from '../utils/config'; import { getScrapeId, @@ -81,7 +82,10 @@ export async function handleInteractExecute( `Using scrape ${scrapeId}` + (storedUrl ? ` (${storedUrl})` : '') + '\n' ); - const body: Record = { origin: 'cli', integration: 'cli' }; + const body: Record = { + origin: getCliOrigin(), + integration: 'cli', + }; if (options.code) { body.code = options.code; diff --git a/src/commands/monitor.ts b/src/commands/monitor.ts index f0eb018286..b592fb1825 100644 --- a/src/commands/monitor.ts +++ b/src/commands/monitor.ts @@ -7,10 +7,10 @@ * firecrawl@4.22.2 exposes monitor methods (createMonitor, * listMonitors, getMonitor, updateMonitor, deleteMonitor, runMonitor, * listMonitorChecks, getMonitorCheck), but its HttpClient injects a top-level - * `origin: js-sdk@` field into every POST/PATCH body and the - * /v2/monitor endpoint rejects that with "Unrecognized key in body". Until the - * SDK strips `origin` for monitor requests (or the API accepts it), we hit - * /v2/monitor directly via fetch — same pattern parse.ts uses. + * `origin: js-sdk@` field into every POST/PATCH body, which older + * /v2/monitor deployments rejected with "Unrecognized key in body". The API now + * accepts `origin` on create and update, and we keep hitting /v2/monitor + * directly via fetch (same pattern parse.ts uses) to stamp the CLI origin. * * Subcommands: * create | list | get | update | delete | run | checks | check @@ -19,6 +19,7 @@ import * as fs from 'fs'; import { Command } from 'commander'; import { getConfig, validateConfig } from '../utils/config'; +import { getCliOrigin } from '../utils/agent-origin'; import { writeOutput } from '../utils/output'; import { integrationHeaders } from '../utils/integration'; @@ -67,11 +68,15 @@ async function monitorRequest( ...integrationHeaders(), }; if (init.body !== undefined) headers['Content-Type'] = 'application/json'; + const body = + init.body && typeof init.body === 'object' && !Array.isArray(init.body) + ? { ...init.body, origin: getCliOrigin() } + : init.body; const response = await fetch(url, { method: init.method ?? 'GET', headers, - body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, }); const payload = (await response.json().catch(() => ({}))) as any; diff --git a/src/commands/parse.ts b/src/commands/parse.ts index cd852b23c6..dc1f5eba36 100644 --- a/src/commands/parse.ts +++ b/src/commands/parse.ts @@ -13,6 +13,7 @@ import type { ParseOptions, ParseResult } from '../types/parse'; import type { ScrapeFormat } from '../types/scrape'; import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; +import { getCliOrigin } from '../utils/agent-origin'; import { handleScrapeOutput } from '../utils/output'; import { integrationHeaders } from '../utils/integration'; @@ -97,6 +98,7 @@ function buildOptionsPayload(options: ParseOptions): Record { const payload: Record = { formats: buildFormats(options), integration: 'cli', + origin: getCliOrigin(), }; if (options.onlyMainContent !== undefined) { diff --git a/src/commands/research.ts b/src/commands/research.ts index 45a03ddc4b..b65da2917e 100644 --- a/src/commands/research.ts +++ b/src/commands/research.ts @@ -1,4 +1,5 @@ import { getClient, isKeylessMode, keylessGet } from '../utils/client'; +import { getCliOrigin } from '../utils/agent-origin'; import { writeOutput } from '../utils/output'; import type { GitHubItem, @@ -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=cli&origin=${encodeURIComponent(getCliOrigin())}`; if (isKeylessMode(options.apiKey, options.apiUrl)) { return (await keylessGet(url)) as T; diff --git a/src/commands/search-feedback.ts b/src/commands/search-feedback.ts index 66dcdf0fb7..d9cb4550f3 100644 --- a/src/commands/search-feedback.ts +++ b/src/commands/search-feedback.ts @@ -1,6 +1,7 @@ import { getConfig, validateConfig } from '../utils/config'; import { getClient } from '../utils/client'; import { integrationHeaders } from '../utils/integration'; +import { getCliOrigin } from '../utils/agent-origin'; export type SearchFeedbackRating = 'good' | 'bad' | 'partial'; @@ -125,7 +126,7 @@ export async function executeSearchFeedback( const url = `${apiUrl}/v2/search/${encodeURIComponent(options.searchId)}/feedback`; const body: Record = { rating: options.rating, - origin: 'cli', + origin: getCliOrigin(), integration: 'cli', }; diff --git a/src/utils/agent-origin.ts b/src/utils/agent-origin.ts new file mode 100644 index 0000000000..4853e56a97 --- /dev/null +++ b/src/utils/agent-origin.ts @@ -0,0 +1,59 @@ +/** + * Request origin for API traffic: `cli.@` when a coding agent + * drives the CLI, `cli@` otherwise. + * + * The agent comes from environment variables that agents set in the processes + * they spawn, not from config files on disk (which only show an agent is + * installed). + */ + +import packageJson from '../../package.json'; + +// Innermost agents first; Claude Code's variables are the most widely inherited. +const AGENT_ENV: ReadonlyArray = [ + [ + 'codex', + [ + 'CODEX_SANDBOX', + 'CODEX_SANDBOX_NETWORK_DISABLED', + 'CODEX_THREAD_ID', + 'CODEX_MANAGED_BY_NPM', + 'CODEX_MANAGED_BY_BUN', + 'CODEX_MANAGED_BY_PNPM', + 'CODEX_MANAGED_BY_VITE_PLUS', + ], + ], + ['gemini-cli', ['GEMINI_CLI']], + ['opencode', ['OPENCODE']], + ['cursor', ['CURSOR_AGENT']], + ['goose', ['GOOSE_TERMINAL']], + ['cline', ['CLINE_ACTIVE']], + ['claude-code', ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT']], +]; + +function isSet(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return !!normalized && normalized !== '0' && normalized !== 'false'; +} + +export function detectAgent( + env: NodeJS.ProcessEnv = process.env +): string | undefined { + const optOut = env.FIRECRAWL_CLI_DISABLE_AGENT_ORIGIN?.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(optOut ?? '')) return undefined; + return AGENT_ENV.find(([, vars]) => + vars.some((name) => isSet(env[name])) + )?.[0]; +} + +export function buildCliOrigin(env: NodeJS.ProcessEnv = process.env): string { + const agent = detectAgent(env); + return `cli${agent ? `.${agent}` : ''}@${packageJson.version}`; +} + +let cachedOrigin: string | undefined; + +export function getCliOrigin(): string { + cachedOrigin ??= buildCliOrigin(); + return cachedOrigin; +} diff --git a/src/utils/client.ts b/src/utils/client.ts index 0ce35d37cd..e01f5e4f1c 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -13,8 +13,13 @@ import { updateConfig, type GlobalConfig, } from './config'; +import { getCliOrigin } from './agent-origin'; import { integrationHeaders, withIntegrationHeader } from './integration'; +// SDK versions without an origin option ignore it, so the request origin +// reaches the API only once the SDK supports it. +type ClientOptionsWithOrigin = FirecrawlClientOptions & { origin?: string }; + let clientInstance: Firecrawl | null = null; const DEFAULT_API_URL = 'https://api.firecrawl.dev'; @@ -54,7 +59,7 @@ export async function keylessRequest( ...KEYLESS_CLI_HEADERS, ...integrationHeaders(), }, - body: JSON.stringify(body), + body: JSON.stringify({ origin: getCliOrigin(), ...body }), }); const json: any = await response.json().catch(() => ({})); if (!response.ok) { @@ -130,12 +135,13 @@ export function getClient( const normalizedApiKey = apiKey === null ? undefined : apiKey; validateConfig(normalizedApiKey); - const clientOptions: FirecrawlClientOptions = { + const clientOptions: ClientOptionsWithOrigin = { apiKey: normalizedApiKey || undefined, apiUrl: apiUrl === null ? undefined : apiUrl, timeoutMs: options.timeoutMs ?? config.timeoutMs, maxRetries: options.maxRetries ?? config.maxRetries, backoffFactor: options.backoffFactor ?? config.backoffFactor, + origin: getCliOrigin(), }; return withIntegrationHeader(new Firecrawl(clientOptions)); @@ -146,12 +152,13 @@ export function getClient( const config = getConfig(); validateConfig(config.apiKey); - const clientOptions: FirecrawlClientOptions = { + const clientOptions: ClientOptionsWithOrigin = { apiKey: config.apiKey || undefined, apiUrl: config.apiUrl || undefined, timeoutMs: config.timeoutMs, maxRetries: config.maxRetries, backoffFactor: config.backoffFactor, + origin: getCliOrigin(), }; clientInstance = withIntegrationHeader(new Firecrawl(clientOptions));