Skip to content
Open
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,30 @@ To install the Firecrawl MCP server into your editors (Cursor, Claude Code, VS C
firecrawl setup mcp
```

For authenticated Claude Code and Codex projects, `firecrawl launch` installs
the full routing state after MCP and skills both succeed: tested routing
guidance in the installed Firecrawl skill descriptions plus a versioned managed
block in the project's `CLAUDE.md` or `AGENTS.md`. The block is project-local,
preserves existing content, and is never written by package installation,
keyless MCP setup, MCP-only setup, or unsupported launch targets.

```bash
# Configure the current project without starting the agent
firecrawl launch claude --install --yes
firecrawl launch codex --install --yes

# Configure and launch in one step
firecrawl launch codex --project ./my-project

# Opt out during setup, or remove the managed block and routed skill prefixes
firecrawl launch claude --project ./my-project --no-router-card
firecrawl launch claude --project ./my-project --remove-router-card
```

Without `--project`, the CLI uses the containing Git worktree, or a safe current
directory when no Git worktree exists. It refuses filesystem-root and home-folder
writes. The MCP server itself never writes project files.

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

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

vi.mock('child_process', () => ({
spawnSync: vi.fn(),
Expand All @@ -25,6 +28,32 @@ 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',
})),
removeRouterCard: vi.fn(() => ({
path: '/project/AGENTS.md',
changed: true,
version: 1,
sha256: 'df867193a6fe011342fce14b770e497cf667ca755e396bb16bbb52c513627951',
})),
resolveRouterCardProject: vi.fn(
(project?: string) => project ?? process.cwd()
),
}));

vi.mock('../../utils/config', () => ({
getApiKey: vi.fn(() => 'fc-test-key'),
}));

vi.mock('../../commands/skills-native', () => ({
removeInstalledRouterGuidance: vi.fn(() => 32),
}));

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

Expand Down Expand Up @@ -74,10 +103,12 @@ describe('handleLaunchCommand', () => {
yes: true,
nativeSkills: true,
quiet: true,
routerGuidanceProject: process.cwd(),
},
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 @@ -126,6 +157,7 @@ describe('handleLaunchCommand', () => {
yes: true,
nativeSkills: true,
quiet: true,
routerGuidanceProject: process.cwd(),
},
ALL_SKILL_REPOS
);
Expand Down Expand Up @@ -180,6 +212,7 @@ describe('handleLaunchCommand', () => {
yes: true,
nativeSkills: true,
quiet: true,
routerGuidanceProject: undefined,
},
ALL_SKILL_REPOS
);
Expand All @@ -202,6 +235,7 @@ describe('handleLaunchCommand', () => {
yes: true,
nativeSkills: true,
quiet: true,
routerGuidanceProject: process.cwd(),
},
ALL_SKILL_REPOS
);
Expand All @@ -226,13 +260,15 @@ describe('handleLaunchCommand', () => {
await handleLaunchCommand('opencode', { skipMcp: true });

expect(installMcp).not.toHaveBeenCalled();
expect(installRouterCard).not.toHaveBeenCalled();
expect(installSkillsForAgent).toHaveBeenCalledWith(
'opencode',
{
global: true,
yes: true,
nativeSkills: true,
quiet: true,
routerGuidanceProject: undefined,
},
ALL_SKILL_REPOS
);
Expand All @@ -241,11 +277,95 @@ describe('handleLaunchCommand', () => {
});
});

it('writes the harness-native router card after authenticated MCP and skills setup', async () => {
await handleLaunchCommand('codex', {
project: '/tmp/example-project',
});

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

it('does not write router state for keyless launch', async () => {
vi.mocked(getApiKey).mockReturnValueOnce(undefined);

await handleLaunchCommand('codex');

expect(installRouterCard).not.toHaveBeenCalled();
expect(installSkillsForAgent).toHaveBeenCalledWith(
'codex',
expect.objectContaining({ routerGuidanceProject: undefined }),
ALL_SKILL_REPOS
);
});

it('does not write router state for an unvalidated harness', async () => {
await handleLaunchCommand('opencode');

expect(installRouterCard).not.toHaveBeenCalled();
expect(installSkillsForAgent).toHaveBeenCalledWith(
'opencode',
expect.objectContaining({ routerGuidanceProject: undefined }),
ALL_SKILL_REPOS
);
});

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

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

it('does not create the rejected card-only state when skills are skipped', async () => {
await handleLaunchCommand('codex', { skipSkills: true });

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

it('removes the managed card without running setup or launching', async () => {
await handleLaunchCommand('codex', {
removeRouterCard: true,
project: '/tmp/example-project',
});

expect(removeRouterCard).toHaveBeenCalledWith(
'codex',
'/tmp/example-project'
);
expect(removeInstalledRouterGuidance).toHaveBeenCalledWith(
'codex',
'/tmp/example-project'
);
expect(installMcp).not.toHaveBeenCalled();
expect(installSkillsForAgent).not.toHaveBeenCalled();
expect(spawnSync).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 All @@ -259,6 +379,7 @@ describe('handleLaunchCommand', () => {
yes: true,
nativeSkills: true,
quiet: true,
routerGuidanceProject: undefined,
},
ALL_SKILL_REPOS
);
Expand All @@ -284,6 +405,7 @@ describe('handleLaunchCommand', () => {
yes: true,
nativeSkills: true,
quiet: true,
routerGuidanceProject: undefined,
},
ALL_SKILL_REPOS
);
Expand Down
89 changes: 89 additions & 0 deletions src/__tests__/commands/skills-native-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
installProjectRouterGuidance,
removeInstalledRouterGuidance,
} from '../../commands/skills-native';
import {
addRouterGuidanceToSkillDescription,
ROUTER_SKILL_DESCRIPTION_PREFIX,
} from '../../utils/router-card';

let home: string | undefined;

afterEach(() => {
vi.unstubAllEnvs();
if (home) rmSync(home, { recursive: true, force: true });
home = undefined;
});

describe('project-scoped router skill guidance', () => {
it('creates a routed project copy without changing canonical global skills', () => {
home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-router-home-'));
vi.stubEnv('HOME', home);
const canonical = path.join(home, '.agents', 'skills', 'firecrawl-example');
const project = path.join(home, 'project');
mkdirSync(canonical, { recursive: true });
const original = `---\nname: firecrawl-example\ndescription: Original.\n---\nBody\n`;
writeFileSync(path.join(canonical, 'SKILL.md'), original);

expect(installProjectRouterGuidance('codex', project)).toBe(1);
expect(readFileSync(path.join(canonical, 'SKILL.md'), 'utf8')).toBe(
original
);
const projectSkill = path.join(
project,
'.agents',
'skills',
'firecrawl-example',
'SKILL.md'
);
expect(readFileSync(projectSkill, 'utf8')).toContain(
ROUTER_SKILL_DESCRIPTION_PREFIX
);
expect(installProjectRouterGuidance('codex', project)).toBe(0);

expect(removeInstalledRouterGuidance('codex', project)).toBe(1);
expect(() => readFileSync(projectSkill, 'utf8')).toThrow();
expect(readFileSync(path.join(canonical, 'SKILL.md'), 'utf8')).toBe(
original
);
expect(removeInstalledRouterGuidance('codex', project)).toBe(0);
});

it('only removes the exact prefix from an existing project-owned skill', () => {
home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-router-home-'));
vi.stubEnv('HOME', home);
const projectSkill = path.join(
home,
'project',
'.claude',
'skills',
'firecrawl-example',
'SKILL.md'
);
mkdirSync(path.dirname(projectSkill), { recursive: true });
const original = `---\nname: firecrawl-example\ndescription: User project skill.\n---\nBody\n`;
writeFileSync(
projectSkill,
addRouterGuidanceToSkillDescription(original),
'utf8'
);

expect(
removeInstalledRouterGuidance('claude-code', path.join(home, 'project'))
).toBe(1);
expect(readFileSync(projectSkill, 'utf8')).toContain(
'description: "User project skill."'
);
});
});
Loading
Loading