Skip to content
Closed
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
48 changes: 48 additions & 0 deletions src/__tests__/commands/setup-router-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Command } from 'commander';
import { describe, expect, it } from 'vitest';
import { addSetupRouterOptions } from '../../commands/setup';

function parse(...argv: string[]): Record<string, unknown> {
const command = addSetupRouterOptions(
new Command().exitOverride().option('--agent <agent>')
);
command.parse(argv, { from: 'user' });
return command.opts();
}

describe('setup MCP router option parsing', () => {
it.each([
[[], undefined],
[['--router-card'], true],
[['--no-router-card'], false],
[['--router-card', '--no-router-card'], false],
[['--no-router-card', '--router-card'], true],
])('uses Commander last-option semantics for %j', (argv, expected) => {
expect(parse(...(argv as string[])).routerCard).toBe(expected);
});

it.each([['--router-card'], ['--no-router-card']])(
'rejects removal with %s',
(routerOption) => {
expect(() => parse(routerOption, '--remove-router-card')).toThrow(
'cannot be used with option'
);
}
);

it('parses explicit project-scoped removal', () => {
expect(
parse(
'--agent',
'codex',
'--project',
'/workspace',
'--remove-router-card'
)
).toMatchObject({
agent: 'codex',
project: '/workspace',
removeRouterCard: true,
});
});
});
170 changes: 170 additions & 0 deletions src/__tests__/commands/setup.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { execFileSync, execSync } from 'child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
Expand Down Expand Up @@ -227,6 +228,175 @@ describe('handleSetupCommand', () => {
);
});

it('writes project router state only after MCP setup succeeds', async () => {
const project = mkdtempSync(
path.join(os.tmpdir(), 'firecrawl-setup-router-')
);
let cardExistedDuringMcpSetup = true;
vi.mocked(execFileSync).mockImplementationOnce(() => {
cardExistedDuringMcpSetup = existsSync(path.join(project, 'AGENTS.md'));
return '' as never;
});

try {
await handleSetupCommand('mcp', {
agent: 'codex',
project,
routerCard: true,
yes: true,
});

expect(cardExistedDuringMcpSetup).toBe(false);
expect(readFileSync(path.join(project, 'AGENTS.md'), 'utf8')).toContain(
'firecrawl-router-card:start'
);
expect(execFileSync).toHaveBeenCalledWith(
'npx',
expect.arrayContaining(['add-mcp@1.14.0', '--agent', 'codex']),
expect.objectContaining({ cwd: project, stdio: 'inherit' })
);
} finally {
rmSync(project, { recursive: true, force: true });
}
});

it('does not write project router state when MCP setup fails', async () => {
const project = mkdtempSync(
path.join(os.tmpdir(), 'firecrawl-setup-router-')
);
vi.mocked(execFileSync).mockImplementationOnce(() => {
throw new Error('setup failed');
});
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('exit 1');
}) as never);

try {
await expect(
handleSetupCommand('mcp', {
agent: 'codex',
project,
routerCard: true,
yes: true,
})
).rejects.toThrow('exit 1');
expect(exit).toHaveBeenCalledWith(1);
expect(existsSync(path.join(project, 'AGENTS.md'))).toBe(false);
expect(existsSync(path.join(project, '.firecrawl'))).toBe(false);
} finally {
rmSync(project, { recursive: true, force: true });
}
});

it('keeps ordinary and explicit default-off MCP setup router-neutral', async () => {
const project = mkdtempSync(
path.join(os.tmpdir(), 'firecrawl-setup-router-')
);
const card = path.join(project, 'AGENTS.md');
const existing =
'before\n\n<!-- firecrawl-router-card:start -->\nold\n<!-- firecrawl-router-card:end -->\n';
writeFileSync(card, existing);

try {
await handleSetupCommand('mcp', {
agent: 'codex',
project,
yes: true,
});
expect(readFileSync(card, 'utf8')).toBe(existing);

await handleSetupCommand('mcp', {
agent: 'codex',
project,
routerCard: false,
yes: true,
});
expect(readFileSync(card, 'utf8')).toBe(existing);
} finally {
rmSync(project, { recursive: true, force: true });
}
});

it('does not inspect malformed router state for explicit default-off setup', async () => {
const project = mkdtempSync(
path.join(os.tmpdir(), 'firecrawl-setup-router-')
);
const card = path.join(project, 'AGENTS.md');
const malformed =
'before\n<!-- firecrawl-router-card:start -->\nunterminated\n';
writeFileSync(card, malformed);

try {
await handleSetupCommand('mcp', {
agent: 'codex',
project,
routerCard: false,
yes: true,
});
expect(execFileSync).toHaveBeenCalledOnce();
expect(readFileSync(card, 'utf8')).toBe(malformed);
} finally {
rmSync(project, { recursive: true, force: true });
}
});

it('removes only managed project router state without running MCP setup', async () => {
const project = mkdtempSync(
path.join(os.tmpdir(), 'firecrawl-setup-router-')
);
const card = path.join(project, 'AGENTS.md');
writeFileSync(
card,
'before\n\n<!-- firecrawl-router-card:start -->\nold\n<!-- firecrawl-router-card:end -->\n'
);

try {
await handleSetupCommand('mcp', {
agent: 'codex',
project,
removeRouterCard: true,
});

expect(execFileSync).not.toHaveBeenCalled();
expect(readFileSync(card, 'utf8')).toBe('before\n');
expect(existsSync(path.join(project, '.firecrawl'))).toBe(false);
} finally {
rmSync(project, { recursive: true, force: true });
}
});

it('fails router actions closed before MCP side effects', async () => {
const project = mkdtempSync(
path.join(os.tmpdir(), 'firecrawl-setup-router-')
);
try {
await expect(
handleSetupCommand('mcp', {
agent: 'codex',
routerCard: true,
})
).rejects.toThrow('--project');
await expect(
handleSetupCommand('mcp', {
agent: 'cursor',
project,
routerCard: true,
})
).rejects.toThrow('claude-code and --agent codex');
await expect(
handleSetupCommand('mcp', {
agent: 'codex',
project,
routerCard: false,
removeRouterCard: true,
})
).rejects.toThrow('conflicts');
expect(execFileSync).not.toHaveBeenCalled();
} finally {
rmSync(project, { recursive: true, force: true });
}
});

it('normalizes launch aliases when reinstalling MCP after auth changes', async () => {
await handleSetupCommand('mcp', {
agent: 'codex-app',
Expand Down
67 changes: 67 additions & 0 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import os from 'os';
import path from 'path';
import readline from 'readline';
import { Command, Option } from 'commander';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { getApiKey } from '../utils/config';
import {
Expand All @@ -28,6 +29,13 @@ import {
WEB_AGENTS,
type WebAgent,
} from '../utils/web-defaults';
import {
assertRouterCardStateSafe,
installRouterCard,
removeRouterCard,
resolveRouterCardProject,
type RouterCardAgent,
} from '../utils/router-card';

export type SetupSubcommand = 'skills' | 'workflows' | 'mcp' | 'defaults';

Expand All @@ -49,13 +57,29 @@ export interface SetupOptions {
nativeSkills?: boolean;
/** Render compact skill install output. */
quiet?: boolean;
project?: string;
routerCard?: boolean;
removeRouterCard?: boolean;
}

const green = '\x1b[32m';
const dim = '\x1b[2m';
const reset = '\x1b[0m';
const ADD_MCP_PACKAGE = 'add-mcp@1.14.0';

export function addSetupRouterOptions(command: Command): Command {
return command
.option('--project <path>', 'Project directory for routing guidance')
.option('--router-card', 'Write project routing guidance after MCP setup')
.option('--no-router-card', 'Leave project routing guidance unchanged')
.addOption(
new Option(
'--remove-router-card',
'Remove managed project routing guidance only'
).conflicts('routerCard')
);
}

const SKILL_REPO_LABELS: Record<string, string> = {
'firecrawl/cli': 'Core CLI skills',
'firecrawl/skills': 'Build skills',
Expand Down Expand Up @@ -383,6 +407,44 @@ export async function installSkillsForAgent(

export async function installMcp(options: SetupOptions): Promise<void> {
const resolvedAgent = resolveMcpAgent(options.agent);
const routerOptionSpecified = options.routerCard !== undefined;
const routerAction =
routerOptionSpecified || Boolean(options.removeRouterCard);
if (options.removeRouterCard && routerOptionSpecified) {
throw new Error(
'--remove-router-card conflicts with --router-card and --no-router-card.'
);
}

let routerAgent: RouterCardAgent | undefined;
let routerProject: string | undefined;
if (routerAction) {
if (!options.agent || !options.project) {
throw new Error('Router-card options require --agent and --project.');
}
if (
resolvedAgent.kind !== 'add-mcp' ||
!['claude-code', 'codex'].includes(resolvedAgent.agent ?? '')
) {
throw new Error(
'Project router cards support --agent claude-code and --agent codex.'
);
}
routerAgent = resolvedAgent.agent === 'claude-code' ? 'claude' : 'codex';
routerProject = resolveRouterCardProject(options.project);
if (options.routerCard || options.removeRouterCard) {
assertRouterCardStateSafe(routerAgent, routerProject);
}
if (options.removeRouterCard) {
const result = removeRouterCard(routerAgent, routerProject);
console.log(`Firecrawl router card removed from ${result.path}.`);
return;
}
if (options.routerCard && !getApiKey()) {
throw new Error('Project router cards require authenticated MCP setup.');
}
}

if (resolvedAgent.kind === 'hermes') {
await installHermesMcp();
return;
Expand All @@ -402,6 +464,10 @@ export async function installMcp(options: SetupOptions): Promise<void> {
}

await installAddMcp(options, resolvedAgent);
if (options.routerCard && routerAgent && routerProject) {
const result = installRouterCard(routerAgent, routerProject);
console.log(`Firecrawl router card configured at ${result.path}.`);
}
}

async function installAddMcp(
Expand Down Expand Up @@ -458,6 +524,7 @@ async function installAddMcp(
execFileSync('npx', args, {
stdio: 'inherit',
env: cleanNpmEnv(),
...(options.project ? { cwd: path.resolve(options.project) } : {}),
});
if (options.quiet) {
const target = resolvedAgent.agent
Expand Down
16 changes: 11 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ import {
findTemplate,
stepAuth,
} from './commands/init';
import { handleMakeDefaultCommand, handleSetupCommand } from './commands/setup';
import {
addSetupRouterOptions,
handleMakeDefaultCommand,
handleSetupCommand,
} from './commands/setup';
import type { SetupSubcommand } from './commands/setup';
import { handleEnvPullCommand } from './commands/env';
import { handleStatusCommand } from './commands/status';
Expand Down Expand Up @@ -2169,7 +2173,7 @@ program
});
});

program
const setupCommand = program
.command('setup')
.description(
'Set up individual firecrawl integrations (skills, workflows, mcp, defaults)'
Expand All @@ -2190,10 +2194,12 @@ program
.option(
'--undo',
'Undo setup defaults by re-enabling native web tools where supported'
)
.action(async (subcommand: SetupSubcommand, options) => {
);
addSetupRouterOptions(setupCommand).action(
async (subcommand: SetupSubcommand, options) => {
await handleSetupCommand(subcommand, options);
});
}
);

program
.command('make')
Expand Down
Loading