Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "firecrawl-cli",
"version": "1.19.29",
"version": "1.19.30",
"description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.",
"main": "dist/index.js",
"bin": {
Expand Down
23 changes: 23 additions & 0 deletions src/__tests__/cli-argv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ describe('CLI argv parsing', () => {
expect(result.stderr).not.toContain('unknown command');
});

testWithBuiltCli(
'exposes explicit keyless MCP setup and launch flags',
() => {
const setup = spawnSync(process.execPath, [cliPath, 'setup', '--help'], {
cwd: process.cwd(),
encoding: 'utf8',
});
const launch = spawnSync(
process.execPath,
[cliPath, 'launch', '--help'],
{
cwd: process.cwd(),
encoding: 'utf8',
}
);

expect(setup.status).toBe(0);
expect(setup.stdout).toContain('--keyless');
expect(launch.status).toBe(0);
expect(launch.stdout).toContain('--keyless');
}
);

testWithBuiltCli(
'parses subcommands when a wrapper leaves the entry script path in argv',
() => {
Expand Down
173 changes: 162 additions & 11 deletions src/__tests__/commands/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,9 @@ import { spawnSync } from 'child_process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { select } from '@inquirer/prompts';
import { handleLaunchCommand } from '../../commands/launch';
import {
installHermesMcp,
installMcp,
installOpenClawMcp,
installSkillsForAgent,
} from '../../commands/setup';
import { installMcp, installSkillsForAgent } from '../../commands/setup';
import { ALL_SKILL_REPOS } from '../../commands/skills-install';
import { getApiKey } from '../../utils/config';

vi.mock('child_process', () => ({
spawnSync: vi.fn(),
Expand All @@ -19,17 +15,23 @@ vi.mock('@inquirer/prompts', () => ({
}));

vi.mock('../../commands/setup', () => ({
installHermesMcp: vi.fn(async () => undefined),
installMcp: vi.fn(async () => undefined),
installOpenClawMcp: vi.fn(async () => undefined),
installSkillsForAgent: vi.fn(async () => undefined),
}));

vi.mock('../../utils/config', () => ({
getApiKey: vi.fn(() => undefined),
}));

describe('handleLaunchCommand', () => {
const originalIsTty = process.stdin.isTTY;
let originalApiKey: string | undefined;

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getApiKey).mockReturnValue(undefined);
originalApiKey = process.env.FIRECRAWL_API_KEY;
delete process.env.FIRECRAWL_API_KEY;
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as never);
Object.defineProperty(process.stdin, 'isTTY', {
configurable: true,
Expand All @@ -38,6 +40,8 @@ describe('handleLaunchCommand', () => {
});

afterEach(() => {
if (originalApiKey === undefined) delete process.env.FIRECRAWL_API_KEY;
else process.env.FIRECRAWL_API_KEY = originalApiKey;
Object.defineProperty(process.stdin, 'isTTY', {
configurable: true,
value: originalIsTty,
Expand Down Expand Up @@ -110,6 +114,23 @@ describe('handleLaunchCommand', () => {
);
});

it('warns when a GUI client may not inherit a launch-scoped stored key', async () => {
vi.mocked(getApiKey).mockReturnValue('fc-stored-key');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);

try {
await handleLaunchCommand('code', { skipSkills: true });

expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
'may reuse an existing GUI process that cannot inherit the stored API key'
)
);
} finally {
warn.mockRestore();
}
});

it('passes extra arguments through to Codex', async () => {
await handleLaunchCommand('codex', {}, ['--sandbox', 'workspace-write']);

Expand Down Expand Up @@ -137,6 +158,54 @@ describe('handleLaunchCommand', () => {
);
});

it('keeps a stored API key indirect while launching an agent with authenticated MCP', async () => {
vi.mocked(getApiKey).mockReturnValue('fc-stored-key');

await handleLaunchCommand('claude');

expect(installMcp).toHaveBeenCalledWith(
{
agent: 'claude-code',
global: true,
yes: true,
quiet: true,
},
expect.objectContaining({ FIRECRAWL_API_KEY: 'fc-stored-key' })
);
expect(spawnSync).toHaveBeenNthCalledWith(
2,
'claude',
[],
expect.objectContaining({
stdio: 'inherit',
env: expect.objectContaining({ FIRECRAWL_API_KEY: 'fc-stored-key' }),
})
);
expect(process.env.FIRECRAWL_API_KEY).toBeUndefined();
});

it('does not pretend a stored API key can persist beyond install-only mode', async () => {
vi.mocked(getApiKey).mockReturnValue('fc-stored-key');
vi.mocked(installMcp).mockRejectedValueOnce(
new Error(
'Secure MCP setup cannot persist a stored API key for future client sessions. Export FIRECRAWL_API_KEY, launch the client through "firecrawl launch <agent>", or configure keyless MCP.'
)
);

await expect(
handleLaunchCommand('claude', { install: true })
).rejects.toThrow('Export FIRECRAWL_API_KEY');

expect(installMcp).toHaveBeenCalledWith({
agent: 'claude-code',
global: true,
yes: true,
quiet: true,
});
expect(spawnSync).not.toHaveBeenCalled();
expect(process.env.FIRECRAWL_API_KEY).toBeUndefined();
});

it('asks which Codex setup to run and can install MCP only', async () => {
const restoreStdin = setStdinTty(true);
vi.mocked(select).mockResolvedValue('mcp');
Expand Down Expand Up @@ -251,7 +320,12 @@ describe('handleLaunchCommand', () => {
it('configures Hermes MCP and skills, then launches Hermes Agent', async () => {
await handleLaunchCommand('hermes');

expect(installHermesMcp).toHaveBeenCalled();
expect(installMcp).toHaveBeenCalledWith({
agent: 'hermes',
global: true,
yes: true,
quiet: true,
});
expect(installSkillsForAgent).toHaveBeenCalledWith(
'hermes-agent',
{
Expand All @@ -273,10 +347,39 @@ describe('handleLaunchCommand', () => {
);
});

it('passes a stored API key only through the launched Hermes process environment', async () => {
vi.mocked(getApiKey).mockReturnValue('fc-stored-key');

await handleLaunchCommand('hermes');

expect(installMcp).toHaveBeenCalledWith(
{
agent: 'hermes',
global: true,
yes: true,
quiet: true,
},
expect.objectContaining({ FIRECRAWL_API_KEY: 'fc-stored-key' })
);
expect(spawnSync).toHaveBeenNthCalledWith(
2,
'hermes',
[],
expect.objectContaining({
env: expect.objectContaining({ FIRECRAWL_API_KEY: 'fc-stored-key' }),
})
);
});

it('configures OpenClaw MCP and skills, then launches the TUI', async () => {
await handleLaunchCommand('openclaw');

expect(installOpenClawMcp).toHaveBeenCalled();
expect(installMcp).toHaveBeenCalledWith({
agent: 'openclaw',
global: true,
yes: true,
quiet: true,
});
expect(installSkillsForAgent).toHaveBeenCalledWith(
'openclaw',
{
Expand All @@ -301,10 +404,58 @@ describe('handleLaunchCommand', () => {
it('can skip skills for Hermes and OpenClaw launch targets', async () => {
await handleLaunchCommand('hermes', { skipSkills: true });

expect(installHermesMcp).toHaveBeenCalled();
expect(installMcp).toHaveBeenCalledWith({
agent: 'hermes',
global: true,
yes: true,
quiet: true,
});
expect(installSkillsForAgent).not.toHaveBeenCalled();
});

it.each([
['claude', 'claude-code', 'claude', []],
['hermes', 'hermes', 'hermes', []],
['openclaw', 'openclaw', 'openclaw', ['tui']],
])(
'can explicitly launch %s keyless without passing a stored API key to the client',
async (target, mcpAgent, command, args) => {
vi.mocked(getApiKey).mockReturnValue('fc-stored-key');

await handleLaunchCommand(target, {
keyless: true,
skipSkills: true,
});

expect(installMcp).toHaveBeenCalledWith({
agent: mcpAgent,
global: true,
yes: true,
quiet: true,
keyless: true,
});
expect(spawnSync).toHaveBeenNthCalledWith(
2,
command,
args,
expect.objectContaining({
env: expect.not.objectContaining({
FIRECRAWL_API_KEY: 'fc-stored-key',
}),
})
);
}
);

it('rejects contradictory keyless and skip-MCP options', async () => {
await expect(
handleLaunchCommand('claude', { keyless: true, skipMcp: true })
).rejects.toThrow('--keyless cannot be combined with --skip-mcp');

expect(installMcp).not.toHaveBeenCalled();
expect(spawnSync).not.toHaveBeenCalled();
});

it('requires an explicit target in non-interactive mode', async () => {
const restoreStdin = setStdinTty(false);

Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/commands/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,22 @@ describe('handleSetupCommand', () => {
'fc-test-key'
);
});
it('accepts a launch-scoped environment while keeping the stored key out of MCP config and argv', async () => {
await installMcp(
{
agent: 'claude-code',
global: true,
yes: true,
},
{ ...process.env, FIRECRAWL_API_KEY: 'fc-test-key' }
);

const args = vi.mocked(execFileSync).mock.calls[0]?.[1];
expect(args).toContain('Authorization: Bearer ${FIRECRAWL_API_KEY}');
expect(args?.join(' ')).not.toContain('fc-test-key');
const subprocessEnv = vi.mocked(execFileSync).mock.calls[0]?.[2]?.env;
expect(subprocessEnv?.FIRECRAWL_API_KEY).toBeUndefined();
});
it('normalizes launch aliases for environment-backed MCP setup', async () => {
process.env.FIRECRAWL_API_KEY = 'fc-test-key';

Expand Down Expand Up @@ -390,6 +406,27 @@ describe('handleSetupCommand', () => {
}
});

it('honors explicit keyless setup for Hermes even when a key is stored', async () => {
const home = mkdtempSync(
path.join(os.tmpdir(), 'firecrawl-hermes-keyless-test-')
);
process.env.HOME = home;

try {
await installMcp({ agent: 'hermes', keyless: true });

const config = readFileSync(
path.join(home, '.hermes', 'config.yaml'),
'utf-8'
);
expect(config).toContain('https://mcp.firecrawl.dev/v2/mcp');
expect(config).not.toContain('Authorization');
expect(config).not.toContain('fc-test-key');
} finally {
rmSync(home, { recursive: true, force: true });
}
});

it('rejects a stored key before invoking the OpenClaw CLI', async () => {
await expect(installOpenClawMcp()).rejects.toThrow(
'Export FIRECRAWL_API_KEY'
Expand All @@ -406,6 +443,15 @@ describe('handleSetupCommand', () => {
expect(config).not.toContain('Bearer fc-test-key');
});

it('honors explicit keyless setup for OpenClaw even when a key is stored', async () => {
await installMcp({ agent: 'openclaw', keyless: true });

const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string;
expect(config).toContain('https://mcp.firecrawl.dev/v2/mcp');
expect(config).not.toContain('Authorization');
expect(config).not.toContain('fc-test-key');
});

it('surfaces a sanitized OpenClaw setup failure', async () => {
process.env.FIRECRAWL_API_KEY = 'fc-test-key';
vi.mocked(execFileSync).mockImplementationOnce(() => {
Expand Down
Loading
Loading