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
109 changes: 109 additions & 0 deletions src/__tests__/commands/init.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,40 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { execSync } from 'child_process';
import { handleInitCommand } from '../../commands/init';
import { installCliRouterCard } from '../../utils/router-card';

vi.mock('child_process', () => ({
execSync: vi.fn(),
}));

vi.mock('../../utils/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../utils/auth')>();
return { ...actual, isAuthenticated: vi.fn(() => true) };
});

vi.mock('../../utils/router-card', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../utils/router-card')>();
return {
...actual,
installCliRouterCard: vi.fn(() => ({
path: '/workspace/AGENTS.md',
changed: true,
version: 2,
sha256:
'f781e09b71c0d7f5a60f5bbf37a0c656cf30ade2876212a5f0dcde6bebaad995',
})),
removeRouterCard: vi.fn(() => ({
path: '/workspace/AGENTS.md',
changed: true,
version: 2,
sha256:
'f781e09b71c0d7f5a60f5bbf37a0c656cf30ade2876212a5f0dcde6bebaad995',
})),
resolveRouterCardProject: vi.fn((project: string) => project),
};
});

describe('handleInitCommand', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -59,4 +88,84 @@ describe('handleInitCommand', () => {
expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] })
);
});

it('installs the CLI-only router card by default after eligible project skills setup', async () => {
await handleInitCommand({
yes: true,
skipInstall: true,
skipAuth: true,
agent: 'codex',
project: '/workspace',
});

expect(installCliRouterCard).toHaveBeenCalledWith('codex', '/workspace');
});

it('honors the per-run router-card opt-out', async () => {
await handleInitCommand({
yes: true,
skipInstall: true,
skipAuth: true,
agent: 'codex',
project: '/workspace',
routerCard: false,
});

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

it('removes managed project routing without running onboarding again', async () => {
const { removeRouterCard } = await import('../../utils/router-card');

await handleInitCommand({
agent: 'codex',
project: '/workspace',
removeRouterCard: true,
});

expect(removeRouterCard).toHaveBeenCalledWith('codex', '/workspace');
expect(execSync).not.toHaveBeenCalled();
});

it('does not write routing outside an explicit supported project state', async () => {
await handleInitCommand({
yes: true,
skipInstall: true,
skipAuth: true,
agent: 'codex',
});
await handleInitCommand({
yes: true,
skipInstall: true,
skipAuth: true,
agent: 'cursor',
project: '/workspace',
});

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

it('rejects router-card setup without an explicit project or skills', async () => {
await expect(
handleInitCommand({
yes: true,
skipInstall: true,
skipAuth: true,
agent: 'codex',
routerCard: true,
})
).rejects.toThrow('requires an explicit --project');

await expect(
handleInitCommand({
yes: true,
skipInstall: true,
skipAuth: true,
skipSkills: true,
agent: 'codex',
project: '/workspace',
routerCard: true,
})
).rejects.toThrow('requires skills');
});
});
26 changes: 26 additions & 0 deletions src/__tests__/utils/router-card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import {
addRouterGuidanceToSkillDescription,
CLI_ROUTER_CARD,
CLI_ROUTER_CARD_SHA256,
installCliRouterCard,
installRouterCard,
removeRouterCard,
resolveRouterCardProject,
Expand Down Expand Up @@ -51,6 +54,29 @@ describe('router card', () => {
expect(ROUTER_CARD).not.toMatch(/api[_ -]?key|fc-[a-z0-9]|mcp\.firecrawl/i);
});

it('keeps the CLI-only card capability-honest and distinct', () => {
expect(CLI_ROUTER_CARD_SHA256).toBe(
'f781e09b71c0d7f5a60f5bbf37a0c656cf30ade2876212a5f0dcde6bebaad995'
);
expect(CLI_ROUTER_CARD).toContain('firecrawl search');
expect(CLI_ROUTER_CARD).toContain('firecrawl scrape');
expect(CLI_ROUTER_CARD).not.toMatch(
/firecrawl_search|firecrawl_scrape|MCP/
);
});

it('replaces the managed full-bundle card with the CLI-only card', () => {
const root = project();
installRouterCard('codex', root);
const receipt = installCliRouterCard('codex', root);

expect(receipt.changed).toBe(true);
expect(receipt.sha256).toBe(CLI_ROUTER_CARD_SHA256);
expect(readFileSync(path.join(root, 'AGENTS.md'), 'utf8')).toBe(
`${CLI_ROUTER_CARD}\n`
);
});

it('does not treat Codex App as the validated Codex CLI surface', () => {
expect(() => resolveRouterCardContext('codex-app' as never)).toThrow(
'Codex CLI'
Expand Down
79 changes: 77 additions & 2 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ import {
WEB_AGENTS,
type WebAgent,
} from '../utils/web-defaults';
import {
installCliRouterCard,
removeRouterCard,
resolveRouterCardProject,
type RouterCardAgent,
} from '../utils/router-card';

export interface InitOptions {
global?: boolean;
Expand All @@ -37,6 +43,9 @@ export interface InitOptions {
apiKey?: string;
browser?: boolean;
template?: string;
routerCard?: boolean;
removeRouterCard?: boolean;
project?: string;
}

const orange = '\x1b[38;5;208m';
Expand Down Expand Up @@ -925,8 +934,9 @@ export async function handleInitCommand(
console.log(` ${orange}🔥 ${bold}firecrawl${reset} ${dim}init${reset}`);
console.log('');

// Non-interactive mode (--yes or --all skips all prompts)
if (options.yes || options.all) {
// Non-interactive mode (--yes or --all skips all prompts). Removal is a
// bounded project-state operation and never enters onboarding prompts.
if (options.yes || options.all || options.removeRouterCard) {
await runNonInteractive(options);
return;
}
Expand Down Expand Up @@ -960,10 +970,55 @@ export async function handleInitCommand(
}

async function runNonInteractive(options: InitOptions): Promise<void> {
const normalizedAgent = options.agent?.trim().toLowerCase();
const routerAgent: RouterCardAgent | undefined =
normalizedAgent === 'claude' || normalizedAgent === 'claude-code'
? 'claude'
: normalizedAgent === 'codex'
? 'codex'
: undefined;
const explicitlyEnableRouter = options.routerCard === true;
if (explicitlyEnableRouter && options.removeRouterCard) {
throw new Error('Cannot combine --router-card with --remove-router-card.');
}
if (options.removeRouterCard) {
if (!routerAgent || !options.project) {
throw new Error(
'--remove-router-card requires --agent claude or --agent codex and an explicit --project <path>.'
);
}
const project = resolveRouterCardProject(options.project);
const receipt = removeRouterCard(routerAgent, project);
console.log(
`${green}✓${reset} ${receipt.changed ? 'Removed' : 'No managed'} Firecrawl CLI router card in ${receipt.path}\n`
);
return;
}
if (explicitlyEnableRouter && options.skipSkills) {
throw new Error('--router-card requires skills; remove --skip-skills.');
}
if (explicitlyEnableRouter && !options.agent) {
throw new Error('--router-card requires --agent claude or --agent codex.');
}
if (explicitlyEnableRouter && !routerAgent) {
throw new Error(
'--router-card currently supports --agent claude or --agent codex.'
);
}
if (explicitlyEnableRouter && !options.project) {
throw new Error('--router-card requires an explicit --project <path>.');
}
const routerEligible = Boolean(
options.routerCard !== false &&
routerAgent &&
options.project &&
!options.skipSkills
);
const steps: string[] = [];
if (!options.skipAuth) steps.push('auth');
if (!options.skipInstall) steps.push('install');
if (!options.skipSkills) steps.push('skills');
if (routerEligible) steps.push('router');
const total = steps.length;
let current = 0;

Expand Down Expand Up @@ -1050,5 +1105,25 @@ async function runNonInteractive(options: InitOptions): Promise<void> {
}
}

if (explicitlyEnableRouter && !isAuthenticated()) {
throw new Error(
'Router-card setup requires an authenticated Firecrawl CLI.'
);
}

if (routerEligible && isAuthenticated()) {
if (!routerAgent) {
throw new Error(
'Router-card setup requires --agent claude or --agent codex.'
);
}
const project = resolveRouterCardProject(options.project);
console.log(`${stepLabel()} Configuring project web routing...`);
const receipt = installCliRouterCard(routerAgent, project);
console.log(
`${green}✓${reset} ${receipt.changed ? 'Installed' : 'Verified'} Firecrawl CLI router card in ${receipt.path}\n`
);
}

printNextSteps(skillCount);
}
13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2153,6 +2153,16 @@ program
.option('--skip-install', 'Skip global CLI installation')
.option('--skip-auth', 'Skip authentication')
.option('--skip-skills', 'Skip skills installation')
.option(
'--router-card',
'Explicitly install the project-local CLI router card (requires --agent and --project)'
)
.option('--no-router-card', 'Skip project-local routing during this init run')
.option(
'--remove-router-card',
'Remove the managed project-local router card, then exit'
)
.option('--project <path>', 'Project directory for the candidate router card')
.action(async (template, options) => {
const globalOptions = program.opts();
await handleInitCommand({
Expand All @@ -2166,6 +2176,9 @@ program
skipInstall: options.skipInstall,
skipAuth: options.skipAuth,
skipSkills: options.skipSkills,
routerCard: options.routerCard,
removeRouterCard: options.removeRouterCard,
project: options.project,
});
});

Expand Down
Loading
Loading