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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1012,3 +1012,26 @@ firecrawl setup workflows
## Documentation

For more details, visit the [Firecrawl Documentation](https://docs.firecrawl.dev).

### Alexandria provider terms (beta)

When a provider returns `THIRD_PARTY_DATA_TERMS_REQUIRED`, review its linked terms.
Read the current provider agreement and metadata with:

```bash
npx firecrawl-cli@alexandria alexandria terms show benzinga --pretty
```

After reviewing it, explicitly accept the exact version and digest for the organization
associated with your Firecrawl API key:

```bash
npx firecrawl-cli@alexandria alexandria terms accept benzinga \
--version '<reviewed-version>' --digest '<reviewed-sha256>' --confirm
```

This posts to `/exchange/provider-terms/accept`. No automatic acceptance or retry
occurs. A `409 terms_changed` requires reviewing the new agreement before retrying.
The terms catalog may remain access-gated even when the acceptance endpoint is
available. A failed catalog lookup does not imply acceptance is unavailable.
After confirmed success, rerun the original provider command; its normal credits apply.
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.23.4-alexandria-beta.10",
"version": "1.23.4-alexandria-beta.13",
"publishConfig": {
"tag": "alexandria"
},
Expand Down
5 changes: 5 additions & 0 deletions src/commands/alexandria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ export async function handleAlexandria(
!envelope.success ||
envelope.data?.alexandria?.some((item: any) => item.error);
if (failed) process.exitCode = 1;
if (envelope.code === 'THIRD_PARTY_DATA_TERMS_REQUIRED') {
console.error(
'Review the provider terms with firecrawl alexandria terms show <provider>. After review, accept with firecrawl alexandria terms accept <provider> --version <version> --digest <sha256> --confirm.'
);
}
writeOutput(
JSON.stringify(envelope, null, options.pretty ? 2 : undefined),
options.output,
Expand Down
119 changes: 119 additions & 0 deletions src/commands/list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { beforeEach, expect, it, vi } from 'vitest';
import { Command } from 'commander';
import { createAlexandriaCommand } from './list';
import { requestAlexandria } from './alexandria';
vi.mock('./alexandria', async (original) => ({
...(await original<typeof import('./alexandria')>()),
requestAlexandria: vi.fn(),
}));
vi.mock('../utils/output', () => ({ writeOutput: vi.fn() }));
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requestAlexandria).mockResolvedValue({
success: true,
data: {
alexandria: [{ data: { level: 'providers', items: [], total: 1 } }],
},
} as any);
});
it.each(
'ai-models apps companies software finance health jobs news people places podcasts government real-estate restaurants shopping social sports skills travel'.split(
' '
)
)('routes %s directly to category discovery', async (category) => {
await new Command()
.addCommand(createAlexandriaCommand())
.parseAsync(['alexandria', category, '--json'], { from: 'user' });
expect(requestAlexandria).toHaveBeenCalledWith(
[
{
provider: 'firecrawl',
capability: 'find-tools',
options: {
categories: [category],
level: 'providers',
limit: 20,
},
},
],
expect.anything()
);
});
it('preserves explicit provider browsing', async () => {
await new Command()
.addCommand(createAlexandriaCommand())
.parseAsync(['alexandria', 'list', 'benzinga', '--json'], { from: 'user' });
expect(requestAlexandria).toHaveBeenCalledWith(
[
{
provider: 'firecrawl',
capability: 'find-tools',
options: { providers: ['benzinga'], level: 'tools', limit: 20 },
},
],
expect.anything()
);
});

it('lists compact provider tools before expanding a selected contract', async () => {
const run = (path: string[]) =>
new Command()
.addCommand(createAlexandriaCommand())
.parseAsync(['alexandria', ...path, '--json'], { from: 'user' });
await run(['people', 'fullenrich']);
expect(requestAlexandria).toHaveBeenLastCalledWith(
[
{
provider: 'firecrawl',
capability: 'find-tools',
options: {
categories: ['people'],
providers: ['fullenrich'],
level: 'tools',
limit: 20,
},
},
],
expect.anything()
);
await run(['people', 'fullenrich', 'people/search']);
expect(requestAlexandria).toHaveBeenLastCalledWith(
[
{
provider: 'firecrawl',
capability: 'find-tools',
options: {
categories: ['people'],
providers: ['fullenrich'],
capabilities: ['people/search'],
level: 'tools',
expand: ['options', 'response', 'examples'],
limit: 20,
},
},
],
expect.anything()
);
});
it('expands category contracts only when requested', async () => {
await new Command()
.addCommand(createAlexandriaCommand())
.parseAsync(['alexandria', 'people', '--contracts', '--json'], {
from: 'user',
});
expect(requestAlexandria).toHaveBeenLastCalledWith(
[
{
provider: 'firecrawl',
capability: 'find-tools',
options: {
categories: ['people'],
level: 'tools',
expand: ['options', 'response', 'examples'],
limit: 20,
},
},
],
expect.anything()
);
});
37 changes: 33 additions & 4 deletions src/commands/list.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createTermsCommand } from './terms';
import { Command, InvalidArgumentError } from 'commander';
import { randomUUID } from 'node:crypto';
import {
Expand All @@ -13,6 +14,7 @@ import { getApiKey, getConfig } from '../utils/config';
type Selectors = Record<string, unknown>;
type ListOptions = AlexandriaOptions & {
category?: boolean;
contracts?: boolean;
limit?: number;
request?: string;
providers?: boolean;
Expand Down Expand Up @@ -161,7 +163,7 @@ function renderCategories(items: Category[]): string {
' Browse a category below, or jump directly to a provider.',
'',
'Calling it',
' Browse: firecrawl alexandria list <category> --category',
' Browse: firecrawl alexandria <category>',
' Tools: firecrawl alexandria list <provider>',
' Inspect: firecrawl alexandria list <provider> <capability>',
" Execute: firecrawl scrape --alexandria <provider>/<capability> --options '<input JSON>'",
Expand Down Expand Up @@ -359,6 +361,14 @@ export async function handleList(
if (options.request)
return fetchPage(parseFindToolsRequest(options.request).options);
if (!path.length) return fetchPage({ level: 'providers', limit });
if (options.contracts && options.category && path.length === 1) {
return fetchPage({
categories: [categoryId(path[0])],
level: 'tools',
expand: ['options', 'response', 'examples'],
limit,
});
}
let scope: Selectors = { providers: [path[0]] };
let remaining = path.slice(1);
let result = options.category
Expand Down Expand Up @@ -428,9 +438,10 @@ export async function handleList(
}
}

export function createListCommand(): Command {
return new Command('list')
.alias('list-tools')
export function createListCommand(name = 'list'): Command {
const command = new Command(name);
if (name === 'list') command.alias('list-tools');
return command
.description(
'Start with the Alexandria category index, then browse providers and tool contracts; discovery only'
)
Expand All @@ -442,6 +453,7 @@ export function createListCommand(): Command {
'--category',
'Treat the first ID as a category when a provider has the same ID'
)
.option('--contracts', 'Include full tool contracts for a category')
.option('--providers', 'List all providers instead of the category index')
.option(
'--limit <number>',
Expand All @@ -468,3 +480,20 @@ export function createListCommand(): Command {
)
.action(handleList);
}

export function createAlexandriaCommand(): Command {
const browse = createListCommand('browse').action(
(path: string[], options: ListOptions) =>
handleList(path, {
...options,
category: path.length > 0,
})
);
return new Command('alexandria')
.description(
'Browse categories with alexandria <category>, or inspect providers with alexandria list'
)
.addCommand(createListCommand())
.addCommand(createTermsCommand())
.addCommand(browse, { isDefault: true, hidden: true });
}
74 changes: 74 additions & 0 deletions src/commands/terms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { afterEach, expect, it, vi } from 'vitest';
import { requestTerms } from './terms';
vi.mock('../utils/config', () => ({
getApiKey: () => 'test-key',
getConfig: () => ({}),
}));
afterEach(() => vi.unstubAllGlobals());
const options = { version: 'v1', digest: 'a'.repeat(64), confirm: true };
it('requires explicit confirmation without making any request', async () => {
const fetcher = vi.fn();
vi.stubGlobal('fetch', fetcher);
await expect(
requestTerms('benzinga', { ...options, confirm: false }, true)
).rejects.toThrow('--confirm');
expect(fetcher).not.toHaveBeenCalled();
});
it('submits only the reviewed provider version and digest to the new API', async () => {
const fetcher = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ success: true, provider: 'benzinga' }))
);
vi.stubGlobal('fetch', fetcher);
expect(await requestTerms('benzinga', options, true)).toMatchObject({
success: true,
});
const [url, init] = fetcher.mock.calls[0]!;
expect(url).toBe('https://api.firecrawl.dev/exchange/provider-terms/accept');
expect(JSON.parse(init.body)).toEqual({
provider: 'benzinga',
version: 'v1',
digest: 'a'.repeat(64),
confirmed: true,
});
expect(init.redirect).toBe('error');
expect(fetcher).toHaveBeenCalledTimes(1);
});
it('preserves changed-terms errors without retrying', async () => {
const fetcher = vi
.fn()
.mockResolvedValue(
new Response(
JSON.stringify({ error: 'Terms changed', code: 'terms_changed' }),
{ status: 409 }
)
);
vi.stubGlobal('fetch', fetcher);
expect(await requestTerms('benzinga', options, true)).toMatchObject({
success: false,
status: 409,
code: 'terms_changed',
});
expect(fetcher).toHaveBeenCalledTimes(1);
});
it('shows only the requested provider and rejects HTML responses', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
providers: [{ provider: 'benzinga', terms: { version: 'v1' } }],
})
)
)
.mockResolvedValueOnce(new Response('<html>'))
);
expect(await requestTerms('benzinga', {})).toMatchObject({
provider: 'benzinga',
terms: { version: 'v1' },
});
await expect(requestTerms('benzinga', {})).rejects.toThrow('non-JSON');
});
Loading
Loading