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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ npx -y firecrawl-cli@latest init -y --browser
- `--browser` opens the browser for Firecrawl authentication automatically
- skills install globally to every detected AI coding agent by default

### Alexandria beta: browse tools

Use the Alexandria beta with your existing Firecrawl login or API key:

```bash
npx firecrawl-cli@alexandria list # visible providers
npx firecrawl-cli@alexandria list finance # providers in a category
npx firecrawl-cli@alexandria list benzinga # provider's tools
npx firecrawl-cli@alexandria list-tools benzinga # same browsing interface
npx firecrawl-cli@alexandria list benzinga --groups # optional tool groups
npx firecrawl-cli@alexandria list benzinga calendar --group
npx firecrawl-cli@alexandria list benzinga <capability> --json
```

Available in `1.23.4-alexandria-beta.8` onward. `list` and `list-tools` are interchangeable, including under `firecrawl alexandria`. Provider IDs work directly; you do not need their category. Lists stay compact until you select a capability, which reveals its inputs, response, examples, and price. Browsable results include next commands; selecting a capability displays the final contract. Generated commands use `firecrawl`; when using `npx`, replace that prefix with `npx firecrawl-cli@alexandria`.

Discovery is free and never executes the listed tools. Results come from the live Find Tools catalog through Scrape and respect your access. `--limit` controls page size (default 20, maximum 100); follow the returned `More` command for the next page. `--json` includes the API envelope, next commands, and receipts for every discovery request.

Provider IDs take precedence over category IDs; use `--category` to select a category explicitly. Exact capability IDs take precedence over group IDs; use `--group` to select a group explicitly. Use `firecrawl search --sources alexandria` to find tools by task, or `firecrawl find-tools` for URL lookup and raw catalog selectors.

### Setup Skills, Workflows, and MCP

If you are using an AI coding agent like Claude Code, you can also install skill groups manually — one command per family:
Expand Down
17 changes: 17 additions & 0 deletions beta-skills/firecrawl-alexandria/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@ A complete contract returned by search needs no additional discovery call. Check

`--sources web` opts out of Alexandria. `--sources web --domain-tools` includes tools for web-result domains. `--no-domain-tools` disables domain matching but does not remove semantic Alexandria results when that source is selected. Keep the normal default unless the user requests a narrower source.

## Browse the catalog progressively

With beta `1.23.4-alexandria-beta.8` or newer, use `list` when the user wants to browse providers, categories, or a known provider's tools:

```sh
npx firecrawl-cli@alexandria list
npx firecrawl-cli@alexandria list finance
npx firecrawl-cli@alexandria list benzinga
npx firecrawl-cli@alexandria list benzinga --groups
npx firecrawl-cli@alexandria list benzinga calendar --group
npx firecrawl-cli@alexandria list benzinga <returned-capability-id> --json
```

The root lists visible providers. A category filters providers; a provider lists compact tools directly. Selecting a capability expands only that contract, including price, inputs, response, and examples. Categories are optional: use returned provider and capability IDs directly. `list-tools` is an alias for `list`; both also work under `alexandria`. `--category` and `--group` resolve ambiguous IDs explicitly.

`list` only calls the free Find Tools meta tool through Scrape. `--json` preserves the API envelope and adds `discoveryRequests` receipts and `nextCommand` navigation. Page size defaults to 20 (`--limit 1–100`); follow the returned `More`/`nextCommand` only when needed. Generated commands start with `firecrawl`; replace that prefix with `npx firecrawl-cli@alexandria` to stay on this beta. Raw `--request` next requests preserve selectors and pagination and must not be mixed with a path or filters.

## Explicit requests for tools: search then the Find Tools meta tool

For “find tools that can look up company filings,” search only tool capabilities:
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.23.4-alexandria-beta.7",
"version": "1.23.4-alexandria-beta.8",
"publishConfig": {
"tag": "alexandria"
},
Expand Down
201 changes: 199 additions & 2 deletions src/__tests__/alexandria-beta.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { execFile } from 'node:child_process';
import { createServer, type Server } from 'node:http';
import { mkdtempSync, rmSync } from 'node:fs';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
Expand All @@ -13,6 +13,7 @@ let server: Server;
let baseUrl: string;
let status = 200;
let response: Record<string, any>;
let responseFor: ((body: any) => Record<string, any>) | undefined;
const home = mkdtempSync(join(tmpdir(), 'alexandria-cli-'));

beforeAll(async () => {
Expand All @@ -25,7 +26,7 @@ beforeAll(async () => {
body: raw ? JSON.parse(raw) : undefined,
});
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(response));
res.end(JSON.stringify(responseFor?.(requests.at(-1)?.body) ?? response));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
Expand All @@ -39,6 +40,7 @@ afterAll(async () => {
beforeEach(() => {
requests.length = 0;
status = 200;
responseFor = undefined;
response = {
success: true,
scrape_id: 'scrape-1',
Expand Down Expand Up @@ -71,6 +73,201 @@ async function cli(args: string[], key = 'fc-test') {
}
}

function catalogue(level: string, items: any[], next?: unknown) {
return {
success: true,
scrape_id: 'discovery-1',
data: {
creditsCost: 0,
alexandria: [
{
provider: 'firecrawl',
capability: 'find-tools',
creditsCost: 0,
data: { level, items, total: items.length, next },
},
],
},
};
}

it('browses live provider IDs directly or through a category without expanding contracts', async () => {
responseFor = (body) => {
const options = body.alexandria[0].options;
if (options.providers?.[0] === 'finance')
return catalogue(options.level, []);
return catalogue(options.level, [
{ id: 'benzinga', provider: 'benzinga', name: 'Benzinga' },
]);
};
for (const args of [[], ['finance'], ['benzinga'], ['finance', 'benzinga']]) {
expect((await cli(['list', ...args])).code).toBe(0);
}
expect((await cli(['alexandria', 'list', 'benzinga', '--groups'])).code).toBe(
0
);
expect((await cli(['list-tools', 'benzinga'])).code).toBe(0);
expect((await cli(['alexandria', 'list-tools', 'benzinga'])).code).toBe(0);
expect(requests.map((request) => request.body.alexandria[0].options)).toEqual(
[
{ level: 'providers', limit: 20 },
{ providers: ['finance'], level: 'tools', limit: 20 },
{ categories: ['finance'], level: 'providers', limit: 20 },
{ providers: ['benzinga'], level: 'tools', limit: 20 },
{ providers: ['finance'], level: 'providers', limit: 20 },
{ categories: ['finance'], level: 'providers', limit: 20 },
{
categories: ['finance'],
providers: ['benzinga'],
level: 'tools',
limit: 20,
},
{ providers: ['benzinga'], level: 'groups', limit: 20 },
{ providers: ['benzinga'], level: 'tools', limit: 20 },
{ providers: ['benzinga'], level: 'tools', limit: 20 },
]
);
expect(
requests.every(
({ url, body }) =>
url === '/v2/scrape' &&
body.alexandria[0].provider === 'firecrawl' &&
body.alexandria[0].capability === 'find-tools'
)
).toBe(true);
});

it('expands only a selected tool and falls back to a compact group listing', async () => {
responseFor = (body) => {
const options = body.alexandria[0].options;
if (options.capabilities?.[0] === 'calendar') return catalogue('tools', []);
return catalogue(options.level, [
{
id: 'benzinga/calendar/earnings',
provider: 'benzinga',
capability: 'calendar/earnings',
creditsCost: 2,
...(options.expand && {
options: { date: { type: 'string' } },
response: { type: 'object' },
}),
},
]);
};
const leaf = await cli(['list', 'benzinga', 'calendar/earnings']);
expect(leaf.code).toBe(0);
expect(leaf.stdout).toContain('Inputs:');
expect(leaf.stdout).toContain('2 credits per call');
expect(requests.at(-1)?.body.alexandria[0].options).toMatchObject({
capabilities: ['calendar/earnings'],
expand: ['options', 'response', 'examples'],
});
const group = await cli(['list', 'benzinga', 'calendar']);
expect(group.code).toBe(0);
expect(group.stdout).not.toContain('Inputs:');
expect(requests.at(-1)?.body.alexandria[0].options).toEqual({
providers: ['benzinga'],
groups: ['calendar'],
level: 'tools',
limit: 20,
});
});

it('preserves scoped next requests, pagination and discovery receipts', async () => {
const next = {
provider: 'firecrawl',
capability: 'find-tools',
options: {
urls: ['https://example.com'],
providers: ['benzinga'],
level: 'tools',
offset: 20,
limit: 20,
},
};
response = catalogue(
'tools',
[
{
id: 'benzinga/news',
provider: 'benzinga',
capability: 'news',
next,
},
],
next
);
const output = join(home, 'catalogue.JSON');
const result = await cli([
'list',
'--request',
JSON.stringify(next),
'--output',
output,
]);
expect(result.code).toBe(0);
expect(requests[0].body.alexandria).toEqual([next]);
const parsed = JSON.parse(readFileSync(output, 'utf8'));
const page = parsed.data.alexandria[0].data;
expect(page.nextCommand).toBe(
`firecrawl list --request '${JSON.stringify(next)}'`
);
expect(page.items[0].nextCommand).toBe(page.nextCommand);
expect(parsed.discoveryRequests).toEqual([
{
requestId: parsed.requestId,
scrape_id: 'discovery-1',
creditsCost: 0,
},
]);
});

it('refuses execution through list and propagates discovery access errors', async () => {
expect(
(
await cli([
'list',
'--request',
'{"provider":"benzinga","capability":"news"}',
])
).code
).toBe(1);
expect(
(
await cli([
'list',
'benzinga',
'--request',
'{"provider":"firecrawl","capability":"find-tools"}',
])
).code
).toBe(1);
expect(requests).toHaveLength(0);
response = {
success: true,
data: {
alexandria: [
{
error: { code: 'provider_disabled', message: 'Provider is disabled' },
},
],
creditsCost: 0,
},
};
const result = await cli(['list', '--json']);
expect(result.code).toBe(1);
expect(JSON.parse(result.stdout).data.alexandria[0].error).toEqual(
response.data.alexandria[0].error
);
expect(JSON.parse(result.stdout).discoveryRequests).toEqual([
{
requestId: expect.any(String),
creditsCost: 0,
},
]);
expect(requests).toHaveLength(1);
});

it('documents the default discovery flow and respects explicit web-only search', async () => {
const help = await cli(['--help']);
expect(help.stdout).toContain('find-tools');
Expand Down
50 changes: 31 additions & 19 deletions src/commands/alexandria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getApiKey } from '../utils/config';
import { writeOutput } from '../utils/output';

type Call = AlexandriaCall & { options: Record<string, unknown> };
type Options = {
export type AlexandriaOptions = {
apiKey?: string;
apiUrl?: string;
requestId?: string;
Expand Down Expand Up @@ -77,10 +77,10 @@ export function apiFailure(error: unknown): Record<string, unknown> {
};
}

export async function handleAlexandria(
export async function requestAlexandria(
calls: Call[],
options: Options
): Promise<void> {
options: AlexandriaOptions
): Promise<Record<string, any>> {
const requestId = options.requestId ?? randomUUID();
if (!/^[A-Za-z0-9._:-]{1,128}$/.test(requestId))
throw new Error('Invalid --request-id.');
Expand All @@ -107,21 +107,42 @@ export async function handleAlexandria(
} catch (error) {
envelope = apiFailure(error);
}
return { ...envelope, requestId };
}

export async function handleAlexandria(
calls: Call[],
options: AlexandriaOptions
): Promise<void> {
const envelope = await requestAlexandria(calls, options);
const failed =
!envelope.success ||
envelope.data?.alexandria?.some((item: any) => item.error);
if (failed) process.exitCode = 1;
writeOutput(
JSON.stringify(
{ ...envelope, requestId },
null,
options.pretty ? 2 : undefined
),
JSON.stringify(envelope, null, options.pretty ? 2 : undefined),
options.output,
!!options.output
);
}

export function parseFindToolsRequest(raw: string): Call {
const next = parseToolOptions(raw);
if (
next.provider !== 'firecrawl' ||
next.capability !== 'find-tools' ||
Object.keys(next).some(
(key) => !['provider', 'capability', 'options'].includes(key)
)
)
throw new Error('--request must be a Find Tools request.');
return {
provider: 'firecrawl',
capability: 'find-tools',
options: parseToolOptions(JSON.stringify(next.options)),
};
}

export function createFindToolsCommand(): Command {
return new Command('find-tools')
.description(
Expand Down Expand Up @@ -153,16 +174,7 @@ export function createFindToolsCommand(): Command {
throw new Error(
'--request cannot be combined with URLs or --options.'
);
const next = parseToolOptions(options.request);
if (
next.provider !== call.provider ||
next.capability !== call.capability ||
Object.keys(next).some(
(key) => !['provider', 'capability', 'options'].includes(key)
)
)
throw new Error('--request must be a Find Tools request.');
call.options = parseToolOptions(JSON.stringify(next.options));
call = parseFindToolsRequest(options.request);
} else if (urls.length) call.options.urls = urls;
await handleAlexandria([call], options);
});
Expand Down
Loading
Loading