Skip to content
Draft
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 @@ -81,6 +81,29 @@ To install the Firecrawl MCP server into your editors (Cursor, Claude Code, VS C
firecrawl setup mcp
```

To co-deliver concise routing guidance into a project's harness-native context,
select one agent and opt in to the router card:

```bash
firecrawl setup mcp --agent claude-code --router-card --project ./my-project
firecrawl setup mcp --agent codex --router-card --project ./my-project
```

The CLI writes an auth-neutral, versioned managed block to `CLAUDE.md` for
Claude Code, `AGENTS.md` for Codex/OpenCode/Hermes/OpenClaw, or
`.cursor/rules/firecrawl.mdc` for Cursor/VS Code. Existing project guidance is
preserved. The card is written only after MCP configuration succeeds; the MCP
server itself never writes project files.

`firecrawl launch <agent>` co-delivers the router card by default after MCP is
configured and launches the agent in the selected project. Opt out explicitly
when needed:

```bash
firecrawl launch codex --project ./my-project
firecrawl launch claude --project ./my-project --no-router-card
```

To make Firecrawl the default web provider for supported AI agents:

```bash
Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/commands/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
installSkillsForAgent,
} from '../../commands/setup';
import { ALL_SKILL_REPOS } from '../../commands/skills-install';
import { installRouterCard } from '../../utils/router-card';

vi.mock('child_process', () => ({
spawnSync: vi.fn(),
Expand All @@ -25,6 +26,15 @@ vi.mock('../../commands/setup', () => ({
installSkillsForAgent: vi.fn(async () => undefined),
}));

vi.mock('../../utils/router-card', () => ({
installRouterCard: vi.fn(() => ({
path: '/project/AGENTS.md',
changed: true,
version: 1,
sha256: 'df867193a6fe011342fce14b770e497cf667ca755e396bb16bbb52c513627951',
})),
}));

describe('handleLaunchCommand', () => {
const originalIsTty = process.stdin.isTTY;

Expand Down Expand Up @@ -78,6 +88,7 @@ describe('handleLaunchCommand', () => {
ALL_SKILL_REPOS
);
expect(spawnSync).not.toHaveBeenCalled();
expect(installRouterCard).toHaveBeenCalledWith('claude', process.cwd());
});

it('supports setup and config as install-mode aliases', async () => {
Expand Down Expand Up @@ -226,6 +237,7 @@ describe('handleLaunchCommand', () => {
await handleLaunchCommand('opencode', { skipMcp: true });

expect(installMcp).not.toHaveBeenCalled();
expect(installRouterCard).not.toHaveBeenCalled();
expect(installSkillsForAgent).toHaveBeenCalledWith(
'opencode',
{
Expand All @@ -241,11 +253,45 @@ describe('handleLaunchCommand', () => {
});
});

it('writes the harness-native router card by default in a selected project', async () => {
await handleLaunchCommand('opencode', {
project: '/tmp/example-project',
});

expect(installRouterCard).toHaveBeenCalledWith(
'opencode',
'/tmp/example-project'
);
expect(spawnSync).toHaveBeenNthCalledWith(
2,
'opencode',
[],
expect.objectContaining({ cwd: '/tmp/example-project' })
);
});

it('supports explicitly disabling router-card delivery', async () => {
await handleLaunchCommand('codex', { routerCard: false });

expect(installRouterCard).not.toHaveBeenCalled();
});

it('never writes a router card when MCP configuration fails', async () => {
vi.mocked(installMcp).mockRejectedValueOnce(new Error('MCP failed'));

await expect(handleLaunchCommand('codex')).rejects.toThrow('MCP failed');

expect(installRouterCard).not.toHaveBeenCalled();
expect(installSkillsForAgent).not.toHaveBeenCalled();
expect(spawnSync).not.toHaveBeenCalled();
});

it('can skip skills for a launch target that normally supports them', async () => {
await handleLaunchCommand('opencode', { skipMcp: true, skipSkills: true });

expect(installMcp).not.toHaveBeenCalled();
expect(installSkillsForAgent).not.toHaveBeenCalled();
expect(installRouterCard).not.toHaveBeenCalled();
});

it('configures Hermes MCP and skills, then launches Hermes Agent', async () => {
Expand Down
41 changes: 41 additions & 0 deletions src/__tests__/commands/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,47 @@ describe('handleSetupCommand', () => {
);
});

it('co-delivers an auth-neutral router card when explicitly requested', async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-router-setup-'));
try {
await handleSetupCommand('mcp', {
agent: 'claude-code',
global: true,
yes: true,
routerCard: true,
project: root,
});

const card = readFileSync(path.join(root, 'CLAUDE.md'), 'utf8');
expect(card).toContain('firecrawl-router-card:version=1');
expect(card).not.toContain('fc-test-key');
expect(card).not.toContain('mcp.firecrawl.dev');
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('requires a single selected harness for MCP router-card delivery', async () => {
await expect(
handleSetupCommand('mcp', { routerCard: true, project: process.cwd() })
).rejects.toThrow('requires one explicit --agent');
await expect(
handleSetupCommand('mcp', {
agent: 'all',
routerCard: true,
project: process.cwd(),
})
).rejects.toThrow('requires one explicit --agent');
await expect(
handleSetupCommand('mcp', {
agent: 'unknown-harness',
routerCard: true,
project: process.cwd(),
})
).rejects.toThrow('not supported');
expect(execSync).not.toHaveBeenCalled();
});

it('normalizes launch aliases when reinstalling MCP after auth changes', async () => {
await handleSetupCommand('mcp', {
agent: 'codex-app',
Expand Down
173 changes: 173 additions & 0 deletions src/__tests__/utils/router-card.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import {
chmodSync,
lstatSync,
mkdtempSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import {
installRouterCard,
resolveRouterCardContext,
ROUTER_CARD,
ROUTER_CARD_SHA256,
ROUTER_CARD_VERSION,
routerCardPath,
} from '../../utils/router-card';

const projects: string[] = [];

function project(): string {
const directory = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-router-'));
projects.push(directory);
return directory;
}

afterEach(() => {
while (projects.length > 0) {
rmSync(projects.pop()!, { recursive: true, force: true });
}
});

describe('router card delivery', () => {
it.each([
['claude', 'CLAUDE.md'],
['claude-code', 'CLAUDE.md'],
['codex', 'AGENTS.md'],
['opencode', 'AGENTS.md'],
['hermes', 'AGENTS.md'],
['openclaw', 'AGENTS.md'],
['vscode', path.join('.cursor', 'rules', 'firecrawl.mdc')],
['cursor', path.join('.cursor', 'rules', 'firecrawl.mdc')],
])('writes %s guidance to its native project context', (agent, relative) => {
const root = project();
const result = installRouterCard(agent, root);

expect(result).toEqual({
path: path.join(root, relative),
changed: true,
version: ROUTER_CARD_VERSION,
sha256: ROUTER_CARD_SHA256,
});
const content = readFileSync(result.path, 'utf8');
if (relative.endsWith('.mdc')) {
expect(content).toBe(
`---\ndescription: Route public web discovery and retrieval through Firecrawl\nalwaysApply: true\n---\n\n${ROUTER_CARD}\n`
);
} else {
expect(content).toBe(`${ROUTER_CARD}\n`);
}
});

it('preserves unrelated content and updates the managed block in place', () => {
const root = project();
const target = path.join(root, 'AGENTS.md');
writeFileSync(
target,
`before\n\n<!-- firecrawl-router-card:start -->\nold\n<!-- firecrawl-router-card:end -->\n\nafter\n`
);

installRouterCard('codex', root);

expect(readFileSync(target, 'utf8')).toBe(
`before\n\n${ROUTER_CARD}\n\nafter\n`
);
});

it('is byte-for-byte idempotent once current', () => {
const root = project();
const first = installRouterCard('codex', root);
const before = readFileSync(first.path, 'utf8');

const second = installRouterCard('codex', root);

expect(second.changed).toBe(false);
expect(readFileSync(first.path, 'utf8')).toBe(before);
});

it('preserves permissions while atomically replacing an existing file', () => {
const root = project();
const target = path.join(root, 'AGENTS.md');
writeFileSync(target, 'project rules\n');
chmodSync(target, 0o600);

installRouterCard('codex', root);

expect(lstatSync(target).mode & 0o777).toBe(0o600);
expect(readdirSync(root).filter((name) => name.endsWith('.tmp'))).toEqual(
[]
);
});

it.each([
'<!-- firecrawl-router-card:start -->\nmissing end\n',
'<!-- firecrawl-router-card:end -->\n',
'<!-- firecrawl-router-card:start v1 -->\n<!-- firecrawl-router-card:end -->\n',
`${ROUTER_CARD}\n${ROUTER_CARD}\n`,
`<!-- firecrawl-router-card:end -->\n${ROUTER_CARD}\n`,
])('refuses malformed or duplicate marker state', (content) => {
const root = project();
const target = path.join(root, 'AGENTS.md');
writeFileSync(target, content);

expect(() => installRouterCard('codex', root)).toThrow(
'malformed or duplicate'
);
expect(readFileSync(target, 'utf8')).toBe(content);
});

it('refuses a symlink destination without changing its referent', () => {
const root = project();
const referent = path.join(root, 'real.md');
writeFileSync(referent, 'do not touch\n');
symlinkSync(referent, path.join(root, 'AGENTS.md'));

expect(() => installRouterCard('codex', root)).toThrow('symlink');
expect(readFileSync(referent, 'utf8')).toBe('do not touch\n');
});

it('refuses a broken symlink destination', () => {
const root = project();
symlinkSync(path.join(root, 'missing.md'), path.join(root, 'AGENTS.md'));

expect(() => installRouterCard('codex', root)).toThrow('symlink');
});

it('refuses a symlinked context directory', () => {
const root = project();
const elsewhere = path.join(root, 'elsewhere');
mkdirSync(elsewhere);
symlinkSync(elsewhere, path.join(root, '.cursor'));

expect(() => installRouterCard('cursor', root)).toThrow('symlink');
expect(readdirSync(elsewhere)).toEqual([]);
});

it('contains routing only and no credentials or hosted MCP URL', () => {
expect(ROUTER_CARD).toContain('firecrawl_search');
expect(ROUTER_CARD).toContain('firecrawl_scrape');
expect(ROUTER_CARD).toContain(
'Respect explicit requests to stay offline, avoid web lookup, or use another named tool.'
);
expect(ROUTER_CARD).not.toMatch(/api[_ -]?key|fc-[a-z0-9]|mcp\.firecrawl/i);
});

it('rejects unknown agents and missing project directories', () => {
expect(() => resolveRouterCardContext('unknown')).toThrow('not supported');
expect(() =>
installRouterCard('codex', path.join(project(), 'missing'))
).toThrow('not a directory');
});

it('resolves paths without writing files', () => {
const root = project();
expect(routerCardPath(root, 'claude')).toBe(path.join(root, 'CLAUDE.md'));
expect(readdirSync(root)).toEqual([]);
});
});
Loading
Loading