diff --git a/src/__tests__/commands/setup-router-options.test.ts b/src/__tests__/commands/setup-router-options.test.ts new file mode 100644 index 000000000..d1f847e29 --- /dev/null +++ b/src/__tests__/commands/setup-router-options.test.ts @@ -0,0 +1,48 @@ +import { Command } from 'commander'; +import { describe, expect, it } from 'vitest'; +import { addSetupRouterOptions } from '../../commands/setup'; + +function parse(...argv: string[]): Record { + const command = addSetupRouterOptions( + new Command().exitOverride().option('--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, + }); + }); +}); diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 4c006d62a..eaa3463ae 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { execFileSync, execSync } from 'child_process'; import { + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -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\nold\n\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\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\nold\n\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', diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 704249669..8df8c5d28 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -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 { @@ -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'; @@ -49,6 +57,9 @@ export interface SetupOptions { nativeSkills?: boolean; /** Render compact skill install output. */ quiet?: boolean; + project?: string; + routerCard?: boolean; + removeRouterCard?: boolean; } const green = '\x1b[32m'; @@ -56,6 +67,19 @@ 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 ', '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 = { 'firecrawl/cli': 'Core CLI skills', 'firecrawl/skills': 'Build skills', @@ -383,6 +407,44 @@ export async function installSkillsForAgent( export async function installMcp(options: SetupOptions): Promise { 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; @@ -402,6 +464,10 @@ export async function installMcp(options: SetupOptions): Promise { } 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( @@ -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 diff --git a/src/index.ts b/src/index.ts index 8949f0732..077548983 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -2169,7 +2173,7 @@ program }); }); -program +const setupCommand = program .command('setup') .description( 'Set up individual firecrawl integrations (skills, workflows, mcp, defaults)' @@ -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')