Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 🔥 Firecrawl CLI

Command-line interface for Firecrawl. Search, scrape, interact, crawl, map, search research papers and developer sources, and run agent jobs directly from your terminal.
Command-line interface for Firecrawl. Search, scrape, interact, crawl, map, search research papers, developer sources, and legal and regulatory sources, and run agent jobs directly from your terminal.

## Installation

Expand Down Expand Up @@ -409,6 +409,35 @@ firecrawl developer "tokio select cancellation safety" --json -o results.json

---

### `legal-regulatory` - Search legal and regulatory sources

Search the Legal and Regulatory Index: primary law and regulatory material from US federal, state, and local government sources, including statutes, regulations, codes, court opinions, and other government publications.

```bash
firecrawl legal-regulatory "food labeling requirements for allergens"
```

#### Options

| Option | Description |
| --------------------- | ----------------------------------------- |
| `--limit <n>` | Number of results (default: 10, max: 100) |
| `-o, --output <path>` | Save to file |
| `--json` | Output the raw response as JSON |
| `--pretty` | Pretty print JSON output |

#### Examples

```bash
# Find state statutes on a topic
firecrawl legal-regulatory "California data breach notification statute" --limit 10

# Keep the raw response
firecrawl legal-regulatory "FDA food labeling regulations" --json -o results.json
```

---

### `research` - Search research papers

Search Firecrawl's research paper index: roughly 43M abstracts, around 90% biomedical (PubMed, bioRxiv, medRxiv) plus arXiv. Use this for biomedical, clinical, and scientific literature rather than scraping PubMed, bioRxiv, or Google Scholar by hand.
Expand Down
34 changes: 34 additions & 0 deletions src/__tests__/cli-argv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,40 @@ describe('CLI argv parsing', () => {
expect(result.stderr).not.toContain('unknown command');
});

testWithBuiltCli(
'lists the legal-regulatory command in root help output',
() => {
const result = spawnSync(process.execPath, [cliPath, '--help'], {
cwd: process.cwd(),
encoding: 'utf8',
});

expect(result.status).toBe(0);
expect(result.stdout).toMatch(/^\s*legal-regulatory\b/m);
}
);

testWithBuiltCli(
'parses the legal-regulatory command and shows its help',
() => {
const result = spawnSync(
process.execPath,
[cliPath, 'legal-regulatory', '--help'],
{
cwd: process.cwd(),
encoding: 'utf8',
}
);

expect(result.status).toBe(0);
expect(result.stdout).toContain('Usage: firecrawl legal-regulatory');
expect(result.stdout).toContain('--limit');
expect(result.stdout).toContain('--json');
expect(result.stdout).toContain('--output');
expect(result.stderr).not.toContain('unknown command');
}
);

testWithBuiltCli(
'describes default search highlights and public developer coverage',
() => {
Expand Down
200 changes: 200 additions & 0 deletions src/__tests__/commands/legal-regulatory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* Tests for legal-regulatory command
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { handleLegalRegulatorySearchCommand } from '../../commands/legal-regulatory';
import { getClient } from '../../utils/client';
import { initializeConfig } from '../../utils/config';
import { writeOutput } from '../../utils/output';
import { setupTest, teardownTest } from '../utils/mock-client';

vi.mock('../../utils/output', () => ({ writeOutput: vi.fn() }));

vi.mock('../../utils/client', async () => {
const actual = await vi.importActual('../../utils/client');
return {
...actual,
getClient: vi.fn(),
};
});

describe('handleLegalRegulatorySearchCommand', () => {
let mockHttpGet: ReturnType<typeof vi.fn>;

// Wrap a payload in the axios envelope returned by `client.http.get`.
const mockLegalRegulatoryResponse = (web: any[]) => ({
data: { success: true, data: { web } },
});

const sampleResult = {
url: 'https://www.ecfr.gov/current/title-21/chapter-I/subchapter-B/part-101',
title: '21 CFR Part 101 -- Food Labeling',
description: 'Food labeling requirements for packaged foods.',
position: 1,
};

beforeEach(() => {
setupTest();
initializeConfig({
apiKey: 'test-api-key',
apiUrl: 'https://api.firecrawl.dev',
});

mockHttpGet = vi.fn();
vi.mocked(getClient).mockReturnValue({
http: { get: mockHttpGet },
} as any);
});

afterEach(() => {
teardownTest();
vi.clearAllMocks();
});

describe('API call generation', () => {
it('calls /v2/search/legal-regulatory with the query', async () => {
mockHttpGet.mockResolvedValue(
mockLegalRegulatoryResponse([sampleResult])
);

await handleLegalRegulatorySearchCommand({ query: 'food labeling' });

expect(mockHttpGet).toHaveBeenCalledTimes(1);
expect(mockHttpGet).toHaveBeenCalledWith(
'/v2/search/legal-regulatory?query=food+labeling&integration=cli'
);
});

it('passes k when a result count is provided', async () => {
mockHttpGet.mockResolvedValue(
mockLegalRegulatoryResponse([sampleResult])
);

await handleLegalRegulatorySearchCommand({
query: 'food labeling',
k: 5,
});

expect(mockHttpGet).toHaveBeenCalledWith(
'/v2/search/legal-regulatory?query=food+labeling&k=5&integration=cli'
);
});

it('passes apiUrl and apiKey to getClient when provided', async () => {
mockHttpGet.mockResolvedValue(mockLegalRegulatoryResponse([]));

await handleLegalRegulatorySearchCommand({
query: 'test',
apiKey: 'other-key',
apiUrl: 'http://localhost:3002',
});

expect(getClient).toHaveBeenCalledWith({
apiKey: 'other-key',
apiUrl: 'http://localhost:3002',
});
});
});

describe('output', () => {
it('renders numbered title, url, and description blocks', async () => {
mockHttpGet.mockResolvedValue(
mockLegalRegulatoryResponse([
sampleResult,
{
url: 'https://www.ecfr.gov/current/title-21/part-102',
title: '21 CFR Part 102',
position: 2,
},
])
);

await handleLegalRegulatorySearchCommand({ query: 'food labeling' });

const [content] = vi.mocked(writeOutput).mock.calls[0];
expect(content).toBe(
[
'## 1. 21 CFR Part 101 -- Food Labeling',
sampleResult.url,
'Food labeling requirements for packaged foods.',
'',
'## 2. 21 CFR Part 102',
'https://www.ecfr.gov/current/title-21/part-102',
].join('\n')
);
});

it('prints a placeholder when there are no results', async () => {
mockHttpGet.mockResolvedValue(mockLegalRegulatoryResponse([]));

await handleLegalRegulatorySearchCommand({ query: 'no hits' });

const [content] = vi.mocked(writeOutput).mock.calls[0];
expect(content).toBe('(no results)');
});

it('tolerates a success response that omits data', async () => {
mockHttpGet.mockResolvedValue({ data: { success: true } });

await handleLegalRegulatorySearchCommand({ query: 'no data field' });

const [content] = vi.mocked(writeOutput).mock.calls[0];
expect(content).toBe('(no results)');
});

it('outputs the raw response as JSON with --json', async () => {
mockHttpGet.mockResolvedValue(
mockLegalRegulatoryResponse([sampleResult])
);

await handleLegalRegulatorySearchCommand({
query: 'food labeling',
json: true,
});

const [content] = vi.mocked(writeOutput).mock.calls[0] as [string];
expect(JSON.parse(content)).toEqual({
success: true,
data: { web: [sampleResult] },
});
});

it('writes to the output file with -o', async () => {
mockHttpGet.mockResolvedValue(
mockLegalRegulatoryResponse([sampleResult])
);

await handleLegalRegulatorySearchCommand({
query: 'food labeling',
output: 'results.md',
});

expect(writeOutput).toHaveBeenCalledWith(
expect.any(String),
'results.md',
true
);
});
});

describe('error handling', () => {
it('exits with code 1 when the request fails', async () => {
mockHttpGet.mockRejectedValue(new Error('boom'));
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as any);
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);

await handleLegalRegulatorySearchCommand({ query: 'test' });

expect(errorSpy).toHaveBeenCalledWith('Error:', 'boom');
expect(exitSpy).toHaveBeenCalledWith(1);

exitSpy.mockRestore();
errorSpy.mockRestore();
});
});
});
78 changes: 78 additions & 0 deletions src/commands/legal-regulatory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { getClient, isKeylessMode, keylessGet } from '../utils/client';
import { writeOutput } from '../utils/output';
import type {
LegalRegulatoryResult,
LegalRegulatorySearchOptions,
LegalRegulatorySearchResponse,
} from '../types/legal-regulatory';

const BASE = '/v2/search/legal-regulatory';

async function getLegalRegulatory<T>(
Comment thread
capy-ai[bot] marked this conversation as resolved.
Outdated
path: string,
options: LegalRegulatorySearchOptions
): Promise<T> {
const url = `${path}${path.includes('?') ? '&' : '?'}integration=cli`;

if (isKeylessMode(options.apiKey, options.apiUrl)) {
return (await keylessGet(url)) as T;
}

const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl });
const response = await (app as any).http.get(url);
return (response?.data ?? {}) as T;
}

function fmtResult(item: LegalRegulatoryResult, index: number): string {
const lines = [
`## ${item.position ?? index + 1}. ${item.title ?? '(untitled)'}`,
item.url,
];
if (item.description) lines.push(item.description);
return lines.join('\n');
}

function fmtLegalRegulatory(data: LegalRegulatorySearchResponse): string {
const results = data.data?.web ?? [];
if (results.length === 0) return '(no results)';
return results.map(fmtResult).join('\n\n');
}

function writeLegalRegulatoryOutput(
data: LegalRegulatorySearchResponse,
readable: string,
options: LegalRegulatorySearchOptions
): void {
const content =
options.json || options.pretty
? options.pretty
? JSON.stringify(data, null, 2)
: JSON.stringify(data)
: readable;
writeOutput(content, options.output, !!options.output);
}

function handleError(error: unknown): never {
console.error(
'Error:',
error instanceof Error ? error.message : 'Unknown error occurred'
);
process.exit(1);
}

export async function handleLegalRegulatorySearchCommand(
options: LegalRegulatorySearchOptions
): Promise<void> {
try {
const params = new URLSearchParams();
params.append('query', options.query);
if (options.k != null) params.append('k', String(options.k));
const data = await getLegalRegulatory<LegalRegulatorySearchResponse>(
`${BASE}?${params.toString()}`,
options
);
writeLegalRegulatoryOutput(data, fmtLegalRegulatory(data), options);
} catch (error) {
handleError(error);
}
}
3 changes: 2 additions & 1 deletion src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,9 @@ function renderCategories(items: Category[]): string {
...items.map((item) => ` ${item.name} (${item.id}): ${item.description}`),
...(!items.length ? [' No categories are currently visible.'] : []),
'',
'Developer and Research indexes have native commands:',
'Developer, Legal and Regulatory, and Research indexes have native commands:',
' firecrawl developer --help',
' firecrawl legal-regulatory --help',
' firecrawl research --help',
'',
'All providers: firecrawl alexandria list --providers',
Expand Down
Loading
Loading