diff --git a/README.md b/README.md index 20170e2d68..d9766319f7 100644 --- a/README.md +++ b/README.md @@ -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 --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: diff --git a/beta-skills/firecrawl-alexandria/SKILL.md b/beta-skills/firecrawl-alexandria/SKILL.md index 134311cedf..c82122aa77 100644 --- a/beta-skills/firecrawl-alexandria/SKILL.md +++ b/beta-skills/firecrawl-alexandria/SKILL.md @@ -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 --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: diff --git a/package.json b/package.json index af69c9b9d0..530fb8c403 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.23.4-alexandria-beta.7", + "version": "1.23.4-alexandria-beta.8", "publishConfig": { "tag": "alexandria" }, diff --git a/src/__tests__/alexandria-beta.test.ts b/src/__tests__/alexandria-beta.test.ts index 51bb1b429d..ad25374c9a 100644 --- a/src/__tests__/alexandria-beta.test.ts +++ b/src/__tests__/alexandria-beta.test.ts @@ -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'; @@ -13,6 +13,7 @@ let server: Server; let baseUrl: string; let status = 200; let response: Record; +let responseFor: ((body: any) => Record) | undefined; const home = mkdtempSync(join(tmpdir(), 'alexandria-cli-')); beforeAll(async () => { @@ -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((resolve) => server.listen(0, '127.0.0.1', resolve)); baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`; @@ -39,6 +40,7 @@ afterAll(async () => { beforeEach(() => { requests.length = 0; status = 200; + responseFor = undefined; response = { success: true, scrape_id: 'scrape-1', @@ -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'); diff --git a/src/commands/alexandria.ts b/src/commands/alexandria.ts index 18f1769842..c2b32cab60 100644 --- a/src/commands/alexandria.ts +++ b/src/commands/alexandria.ts @@ -6,7 +6,7 @@ import { getApiKey } from '../utils/config'; import { writeOutput } from '../utils/output'; type Call = AlexandriaCall & { options: Record }; -type Options = { +export type AlexandriaOptions = { apiKey?: string; apiUrl?: string; requestId?: string; @@ -77,10 +77,10 @@ export function apiFailure(error: unknown): Record { }; } -export async function handleAlexandria( +export async function requestAlexandria( calls: Call[], - options: Options -): Promise { + options: AlexandriaOptions +): Promise> { const requestId = options.requestId ?? randomUUID(); if (!/^[A-Za-z0-9._:-]{1,128}$/.test(requestId)) throw new Error('Invalid --request-id.'); @@ -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 { + 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( @@ -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); }); diff --git a/src/commands/list.ts b/src/commands/list.ts new file mode 100644 index 0000000000..e84892bbef --- /dev/null +++ b/src/commands/list.ts @@ -0,0 +1,341 @@ +import { Command, InvalidArgumentError } from 'commander'; +import { + apiFailure, + parseFindToolsRequest, + requestAlexandria, + type AlexandriaOptions, +} from './alexandria'; +import { writeOutput } from '../utils/output'; + +type Selectors = Record; +type ListOptions = AlexandriaOptions & { + category?: boolean; + groups?: boolean; + group?: boolean; + limit?: number; + request?: string; +}; +type Item = { + id: string; + provider: string; + name?: string; + description?: string; + attribution?: string; + group?: string; + capability?: string; + toolCount?: number; + creditsCost?: number; + perRecord?: boolean; + options?: unknown; + requiresOneOf?: unknown; + response?: unknown; + example?: unknown; + next?: unknown; + nextCommand?: string; +}; +type Page = { + level: 'providers' | 'groups' | 'tools'; + items: Item[]; + total: number; + next?: unknown; + nextCommand?: string; +}; + +class DiscoveryFailure extends Error { + constructor(readonly envelope: Record) { + super(envelope.error ?? 'Tool discovery failed.'); + } +} + +function quote(value: string): string { + return /^[a-zA-Z0-9_./:-]+$/.test(value) + ? value + : `'${value.replaceAll("'", "'\\''")}'`; +} + +function nextCommand(request: unknown): string { + const call = parseFindToolsRequest(JSON.stringify(request)); + return `firecrawl list --request ${quote(JSON.stringify(call))}`; +} + +function itemCommand(item: Item): string | undefined { + if (!item.next) return undefined; + const { options } = parseFindToolsRequest(JSON.stringify(item.next)); + const category = Array.isArray(options.categories) + ? options.categories[0] + : undefined; + const only = (value: unknown, id: string | undefined) => + id === undefined + ? value === undefined + : Array.isArray(value) && value.length === 1 && value[0] === id; + // Keep scoped and future selectors intact when a short path cannot express them. + if ( + ![item.provider, item.capability, item.group, category].every( + (id) => + id === undefined || + (typeof id === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_./:-]*$/.test(id)) + ) || + !only(options.providers, item.provider) || + !only(options.categories, category) || + !only(options.capabilities, item.capability) || + (options.groups !== undefined && !only(options.groups, item.group)) || + (options.offset !== undefined && options.offset !== 0) || + Object.keys(options).some( + (key) => + ![ + 'providers', + 'categories', + 'groups', + 'capabilities', + 'level', + 'limit', + 'offset', + 'expand', + ].includes(key) + ) + ) + return nextCommand(item.next); + const base = `firecrawl list ${category ? `${quote(category)} ` : ''}${quote(item.provider)}`; + const flags = + (category ? ' --category' : '') + + (typeof options.limit === 'number' && options.limit !== 20 + ? ` --limit ${options.limit}` + : ''); + if (item.capability) return `${base} ${quote(item.capability)}${flags}`; + if (item.group) return `${base} ${quote(item.group)} --group${flags}`; + return `${base}${flags}`; +} + +function render(page: Page): string { + const lines = [ + `${page.level[0].toUpperCase() + page.level.slice(1)} (${page.items.length} of ${page.total})`, + '', + ]; + for (const item of page.items) { + lines.push(`${item.id}${item.name ? ` ${item.name}` : ''}`); + if (item.description) lines.push(` ${item.description}`); + if (item.toolCount !== undefined) lines.push(` ${item.toolCount} tools`); + if (item.creditsCost !== undefined) + lines.push( + ` ${item.creditsCost} credits per ${item.perRecord ? 'record' : 'call'}` + ); + if (item.attribution) lines.push(` ${item.attribution}`); + for (const [label, value] of [ + ['Inputs', item.options], + ['Required alternatives', item.requiresOneOf], + ['Returns', item.response], + ['Example', item.example], + ] as const) { + if (value !== undefined) + lines.push(`\n ${label}:\n${JSON.stringify(value, null, 2)}`); + } + if (item.nextCommand) lines.push(` Next: ${item.nextCommand}`); + if (item.options !== undefined && item.capability) { + lines.push( + ` Execute after filling the inputs: firecrawl scrape --alexandria ${quote(`${item.provider}/${item.capability}`)} --options ''` + ); + } + lines.push(''); + } + if (!page.items.length) + lines.push('No matching tools are visible for these selectors.', ''); + if (page.nextCommand) lines.push(`More: ${page.nextCommand}`); + return lines.join('\n'); +} + +export async function handleList( + path: string[], + options: ListOptions +): Promise { + const asJson = + options.json || + options.pretty || + options.output?.toLowerCase().endsWith('.json'); + const receipts: { + requestId: string; + scrape_id?: string; + creditsCost?: number; + }[] = []; + try { + if ( + options.request && + (path.length || + options.category || + options.groups || + options.group || + options.limit !== undefined) + ) + throw new Error( + '--request cannot be combined with a path or list filters.' + ); + if (path.some((part) => !part.trim() || part.length > 200)) + throw new Error('Use non-empty catalogue IDs of at most 200 characters.'); + if (options.group && (options.groups || path.length < 2)) + throw new Error( + '--group requires a provider and group path, without --groups.' + ); + if ((options.category || options.groups) && !path.length) + throw new Error('Provide a category or provider ID.'); + const limit = options.limit ?? 20; + + async function fetchPage(selectors: Selectors) { + const envelope = await requestAlexandria( + [ + { + provider: 'firecrawl', + capability: 'find-tools', + options: selectors, + }, + ], + options + ); + receipts.push({ + requestId: envelope.requestId, + scrape_id: envelope.scrape_id, + creditsCost: envelope.data?.creditsCost, + }); + const item = envelope.data?.alexandria?.[0]; + if (!envelope.success || item?.error) + throw new DiscoveryFailure(envelope); + const page = item?.data as Page | undefined; + if ( + !page || + !['providers', 'groups', 'tools'].includes(page.level) || + !Array.isArray(page.items) || + !Number.isInteger(page.total) || + page.items.some( + (row) => + !row || + typeof row.id !== 'string' || + typeof row.provider !== 'string' + ) + ) + throw new Error('Find Tools returned an invalid catalogue response.'); + return { envelope, page }; + } + + async function resolve() { + if (options.request) + return fetchPage(parseFindToolsRequest(options.request).options); + if (!path.length) return fetchPage({ level: 'providers', limit }); + const providerLevel = options.groups ? 'groups' : 'tools'; + let scope: Selectors = { providers: [path[0]] }; + let remaining = path.slice(1); + let result = options.category + ? undefined + : await fetchPage({ + ...scope, + level: remaining.length ? 'providers' : providerLevel, + limit, + }); + if (!result?.page.total) { + scope = { categories: [path[0]] }; + result = await fetchPage({ ...scope, level: 'providers', limit }); + if (!result.page.total || !remaining.length) return result; + scope.providers = [remaining[0]]; + remaining = remaining.slice(1); + if (!remaining.length) { + if (options.group) + throw new Error('Provide a group ID after the provider ID.'); + return fetchPage({ ...scope, level: providerLevel, limit }); + } + } + if (!remaining.length) return result; + if (options.groups) + throw new Error( + '--groups lists a provider’s groups; omit it when selecting a tool.' + ); + const selected = remaining.join('/'); + if (!options.group) { + const contract = await fetchPage({ + ...scope, + capabilities: [selected], + level: 'tools', + expand: ['options', 'response', 'examples'], + limit, + }); + if (contract.page.total) return contract; + } + return fetchPage({ ...scope, groups: [selected], level: 'tools', limit }); + } + + const { envelope, page } = await resolve(); + page.items = page.items.map((item) => ({ + ...item, + nextCommand: itemCommand(item), + })); + if (page.next) page.nextCommand = nextCommand(page.next); + const output = asJson + ? JSON.stringify( + { ...envelope, discoveryRequests: receipts }, + null, + options.pretty ? 2 : undefined + ) + : render(page); + writeOutput(output, options.output, !!options.output); + } catch (error) { + process.exitCode = 1; + const failure = + error instanceof DiscoveryFailure ? error.envelope : apiFailure(error); + const message = + failure.error ?? + failure.data?.alexandria?.[0]?.error?.message ?? + 'Tool discovery failed.'; + writeOutput( + asJson + ? JSON.stringify( + { ...failure, discoveryRequests: receipts }, + null, + options.pretty ? 2 : undefined + ) + : `Error: ${message}`, + options.output, + !!options.output + ); + } +} + +export function createListCommand(): Command { + return new Command('list') + .alias('list-tools') + .description( + 'Browse providers, categories and tool contracts through Find Tools; discovery only' + ) + .argument( + '[path...]', + 'Provider or category, optionally followed by a group or capability' + ) + .option( + '--category', + 'Treat the first ID as a category when a provider has the same ID' + ) + .option('--groups', 'List a provider’s tool groups instead of its tools') + .option( + '--group', + 'Treat the final path as a group instead of a capability' + ) + .option( + '--limit ', + 'Results per page (1-100; default: 20)', + (raw) => { + const value = Number(raw); + if (!Number.isInteger(value) || value < 1 || value > 100) + throw new InvalidArgumentError('Use an integer from 1 to 100.'); + return value; + } + ) + .option( + '--request ', + 'Follow a complete next or pagination request from Find Tools' + ) + .option('-k, --api-key ', 'Firecrawl API key') + .option('--api-url ', 'Firecrawl API URL') + .option('-o, --output ', 'Output file') + .option('--json', 'Output JSON with discovery receipts and next commands') + .option('--pretty', 'Format JSON') + .addHelpText( + 'after', + '\nExamples:\n firecrawl list\n firecrawl list finance\n firecrawl list benzinga\n firecrawl list benzinga --groups\n firecrawl list benzinga calendar --group\n firecrawl list benzinga --json\n\nProvider IDs take precedence over categories; exact capabilities take precedence over groups.\nNo listed tool is executed. Search by task with firecrawl search --sources alexandria.\n' + ) + .action(handleList); +} diff --git a/src/index.ts b/src/index.ts index 71c3afefb0..2f1308fe29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,6 +80,7 @@ import type { ScrapeFormat } from './types/scrape'; import type { RelatedPapersOptions } from './types/research'; import type { AgentWebhookConfig } from 'firecrawl'; import { createCreateCommand } from './commands/create'; +import { createListCommand } from './commands/list'; // Initialize global configuration from environment variables initializeConfig(); @@ -2199,6 +2200,12 @@ program.addCommand(createParseCommand()); program.addCommand(createMonitorCommand()); program.addCommand(createSearchCommand()); program.addCommand(createFindToolsCommand()); +program.addCommand(createListCommand()); +program.addCommand( + new Command('alexandria') + .description('Alexandria catalogue commands') + .addCommand(createListCommand()) +); program.addCommand(createDeveloperCommand()); program.addCommand(createResearchCommand()); program.addCommand(createFeedbackCommand());