diff --git a/.gitignore b/.gitignore index 9e67acb9ae..4eadf3c01d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ pnpm-debug.log* # IDE .vscode/ .idea/ +.cursor/ *.swp *.swo *~ diff --git a/README.md b/README.md index 6576734427..023be23532 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,39 @@ firecrawl setup skills firecrawl setup workflows ``` -To install the Firecrawl MCP server into your editors (Cursor, Claude Code, VS Code, etc.): +To install the Firecrawl MCP server into your coding agents: ```bash firecrawl setup mcp ``` +This detects which agents you have installed, lists those in a picker +(already selected), and asks whether to add rules telling those agents to +prefer Firecrawl for web search and scraping. Supported agents are Claude Code, +Cursor, VS Code, Codex, OpenCode, Hermes Agent, and OpenClaw. + +Setup writes to your global agent settings, so one command puts Firecrawl on +every agent you already use. Pass agent flags to skip the picker, or `-y` to +configure every detected agent (MCP only): + +```bash +firecrawl setup mcp --claude --cursor # skip the picker +firecrawl setup mcp -y # every detected agent, MCP only +firecrawl setup mcp -y --rules # ...and install the rules too +``` + +Rerun the command any time to update an existing setup or add another agent; it +edits only the Firecrawl entry and leaves the rest of each config alone. + +Your API key is never written into an agent config. When `FIRECRAWL_API_KEY` is +exported in the environment your agents run under, each agent gets a reference +to that variable in the syntax it understands. Otherwise setup stays keyless, +which still serves search, scrape, and parse under an anonymous rate limit. Use +`--keyless` to force the anonymous path even when a key is available, or +`--oauth` to sign in from the agent instead. Sign-in writes a different +endpoint, so each agent runs the browser flow itself on first use and setup +prints the step it needs. Pass either `--oauth` or `--keyless`, not both. + To make Firecrawl the default web provider for supported AI agents: ```bash diff --git a/package.json b/package.json index 097a4ee0e5..659de2a6c6 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,8 @@ "@inquirer/prompts": "^8.2.1", "commander": "^14.0.2", "firecrawl": "4.24.0", + "jsonc-parser": "3.3.1", + "toml-eslint-parser": "^0.12.0", "yaml": "^2.9.0", "zod-to-json-schema": "3.24.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5126333bf..0c2029e2af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: firecrawl: specifier: 4.24.0 version: 4.24.0 + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 + toml-eslint-parser: + specifier: ^0.12.0 + version: 0.12.0 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -627,6 +633,10 @@ packages: engines: {node: '>=18'} hasBin: true + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -752,6 +762,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -949,6 +962,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toml-eslint-parser@0.12.0: + resolution: {integrity: sha512-4qHgkGXl0LyFp/3aNoi6dKWuPuxFsCiDtBl5IbJljeYR57+5l3pJHJEW9xPSOu2U1drGlG82tpGqkJz/uJZ2Fw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + typescript-event-target@1.1.2: resolution: {integrity: sha512-TvkrTUpv7gCPlcnSoEwUVUBwsdheKm+HF5u2tPAKubkIGMfovdSizCTaZRY/NhR8+Ijy8iZZUapbVQAsNrkFrw==} @@ -1515,6 +1532,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + eslint-visitor-keys@3.4.3: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -1631,6 +1650,8 @@ snapshots: isexe@2.0.0: {} + jsonc-parser@3.3.1: {} + lilconfig@3.1.3: {} lint-staged@15.5.2: @@ -1827,6 +1848,10 @@ snapshots: dependencies: is-number: 7.0.0 + toml-eslint-parser@0.12.0: + dependencies: + eslint-visitor-keys: 3.4.3 + typescript-event-target@1.1.2: {} typescript@5.9.3: {} diff --git a/src/__tests__/commands/doctor.test.ts b/src/__tests__/commands/doctor.test.ts index a66a98adf8..8db6b3e732 100644 --- a/src/__tests__/commands/doctor.test.ts +++ b/src/__tests__/commands/doctor.test.ts @@ -6,13 +6,26 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { compareVersions } from '../../utils/npm-registry'; -import { hasFirecrawlMcpEntry } from '../../utils/agents'; +import { detectAgents, hasFirecrawlMcpEntry } from '../../utils/agents'; import { runChecks, runSupportAsk } from '../../commands/doctor'; import { initializeConfig, resetConfig } from '../../utils/config'; +import { ALL_MCP_CLIENT_IDS, createMcpContext } from '../../utils/mcp-clients'; +import { setupMcpClient } from '../../utils/mcp-install'; + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: vi.fn(() => { + throw new Error('openclaw missing'); + }), + }; +}); const mockFetch = vi.fn(); global.fetch = mockFetch as unknown as typeof fetch; @@ -78,6 +91,310 @@ describe('hasFirecrawlMcpEntry', () => { }) ).toBe(true); }); + + it('ignores a nested servers map that is not the agent MCP config', () => { + expect( + hasFirecrawlMcpEntry({ + 'someExtension.config': { servers: { firecrawl: {} } }, + }) + ).toBe(false); + }); + + it('detects firecrawl under OpenCode top-level mcp', () => { + expect( + hasFirecrawlMcpEntry({ + mcp: { + firecrawl: { + type: 'remote', + url: 'https://mcp.firecrawl.dev/v2/mcp', + }, + }, + }) + ).toBe(true); + }); +}); + +describe('detectAgents', () => { + let tmpHome: string; + let homedirSpy: ReturnType; + let originalClaudeConfigDir: string | undefined; + let originalCodexHome: string | undefined; + let originalHermesHome: string | undefined; + let originalAppData: string | undefined; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-agents-')); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + originalCodexHome = process.env.CODEX_HOME; + originalHermesHome = process.env.HERMES_HOME; + originalAppData = process.env.APPDATA; + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CODEX_HOME; + delete process.env.HERMES_HOME; + process.env.APPDATA = path.join(tmpHome, 'AppData', 'Roaming'); + vi.mocked(execFileSync).mockReset(); + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('openclaw missing'); + }); + }); + + afterEach(() => { + homedirSpy.mockRestore(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + if (originalHermesHome === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = originalHermesHome; + if (originalAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = originalAppData; + }); + + it('reports OpenCode registered from a JSONC config', async () => { + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'opencode.json'), + `{ + // remote Firecrawl + "mcp": { + "firecrawl": { + "type": "remote", + "url": "https://mcp.firecrawl.dev/v2/mcp", + "enabled": true, + }, + }, +} +` + ); + + const opencode = (await detectAgents(tmpHome)).find( + (agent) => agent.id === 'opencode' + ); + expect(opencode?.installed).toBe(true); + expect(opencode?.mcpRegistered).toBe(true); + }); + + it('reports Hermes registered after setup writes config.yaml', async () => { + const dir = path.join(tmpHome, '.hermes'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'config.yaml'), + 'mcp_servers:\n firecrawl:\n url: https://mcp.firecrawl.dev/v2/mcp\n' + ); + + const hermes = (await detectAgents(tmpHome)).find( + (agent) => agent.id === 'hermes' + ); + expect(hermes?.installed).toBe(true); + expect(hermes?.mcpRegistered).toBe(true); + }); + + it('still sees Firecrawl in JSONC after a recoverable parse error', async () => { + const dir = path.join(tmpHome, '.cursor'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'mcp.json'), + `{ + "theme": "dark" + "mcpServers": { "firecrawl": { "url": "https://mcp.firecrawl.dev/v2/mcp" } } +} +` + ); + + const cursor = (await detectAgents(tmpHome)).find( + (agent) => agent.id === 'cursor' + ); + expect(cursor?.installed).toBe(true); + expect(cursor?.mcpRegistered).toBe(true); + }); + + it('does not treat an unreadable JSON config as registered', async () => { + const dir = path.join(tmpHome, '.cursor'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'mcp.json'), '{ oops'); + + const cursor = (await detectAgents(tmpHome)).find( + (agent) => agent.id === 'cursor' + ); + expect(cursor?.mcpRegistered).toBe(false); + }); + + it('does not treat a YAML comment mentioning firecrawl as registration', async () => { + const dir = path.join(tmpHome, '.hermes'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'config.yaml'), + '# firecrawl:\nmcp_servers:\n github:\n command: npx\n' + ); + + const hermes = (await detectAgents(tmpHome)).find( + (agent) => agent.id === 'hermes' + ); + expect(hermes?.mcpRegistered).toBe(false); + }); + + it('detects OpenClaw from a runnable PATH binary without ~/.openclaw', async () => { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-openclaw-bin-')); + const binary = path.join( + bin, + process.platform === 'win32' ? 'openclaw.cmd' : 'openclaw' + ); + fs.writeFileSync(binary, ''); + if (process.platform !== 'win32') fs.chmodSync(binary, 0o755); + const previousPath = process.env.PATH; + process.env.PATH = bin; + vi.mocked(execFileSync).mockReturnValue( + JSON.stringify({ + name: 'firecrawl', + url: 'https://mcp.firecrawl.dev/v2/mcp', + }) + ); + + try { + const openclaw = (await detectAgents(tmpHome)).find( + (agent) => agent.id === 'openclaw' + ); + expect(openclaw?.installed).toBe(true); + expect(openclaw?.mcpRegistered).toBe(true); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + fs.rmSync(bin, { recursive: true, force: true }); + } + }); + + it('reports OpenClaw registered via openclaw mcp show --json', async () => { + fs.mkdirSync(path.join(tmpHome, '.openclaw'), { recursive: true }); + vi.mocked(execFileSync).mockReturnValue( + JSON.stringify({ + name: 'firecrawl', + url: 'https://mcp.firecrawl.dev/v2/mcp', + }) + ); + + const openclaw = (await detectAgents(tmpHome)).find( + (agent) => agent.id === 'openclaw' + ); + expect(openclaw?.installed).toBe(true); + expect(openclaw?.mcpRegistered).toBe(true); + expect(execFileSync).toHaveBeenCalledWith( + 'openclaw', + ['mcp', 'show', 'firecrawl', '--json'], + expect.objectContaining({ encoding: 'utf8' }) + ); + }); + + it('does not treat an OpenClaw config file as registration', async () => { + const dir = path.join(tmpHome, '.openclaw'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'openclaw.json'), + JSON.stringify({ + mcp: { + servers: { + firecrawl: { + url: 'https://mcp.firecrawl.dev/v2/mcp', + }, + }, + }, + }) + ); + + const openclaw = (await detectAgents(tmpHome)).find( + (agent) => agent.id === 'openclaw' + ); + expect(openclaw?.installed).toBe(true); + expect(openclaw?.mcpRegistered).toBe(false); + }); + + it('follows CLAUDE_CONFIG_DIR for Claude Code detection', async () => { + const override = path.join(tmpHome, 'custom-claude'); + fs.mkdirSync(override, { recursive: true }); + fs.writeFileSync( + path.join(override, '.claude.json'), + JSON.stringify({ + mcpServers: { + firecrawl: { type: 'http', url: 'https://mcp.firecrawl.dev/v2/mcp' }, + }, + }) + ); + process.env.CLAUDE_CONFIG_DIR = override; + + const claude = (await detectAgents(tmpHome)).find( + (agent) => agent.id === 'claude-code' + ); + expect(claude?.installed).toBe(true); + expect(claude?.mcpRegistered).toBe(true); + }); + + it('follows CODEX_HOME for Codex detection and registration', async () => { + const override = path.join(tmpHome, 'custom-codex'); + fs.mkdirSync(override, { recursive: true }); + fs.writeFileSync( + path.join(override, 'config.toml'), + '[mcp_servers."firecrawl"]\nurl = "https://mcp.firecrawl.dev/v2/mcp"\n' + ); + process.env.CODEX_HOME = override; + + const codex = (await detectAgents(tmpHome)).find( + (agent) => agent.id === 'codex' + ); + expect(codex?.installed).toBe(true); + expect(codex?.mcpRegistered).toBe(true); + expect(codex?.configPaths[0]).toBe(path.join(override, 'config.toml')); + }); + + it('follows HERMES_HOME for Hermes detection and registration', async () => { + const override = path.join(tmpHome, 'custom-hermes'); + fs.mkdirSync(override, { recursive: true }); + fs.writeFileSync( + path.join(override, 'config.yaml'), + 'mcp_servers:\n firecrawl:\n url: https://mcp.firecrawl.dev/v2/mcp\n' + ); + process.env.HERMES_HOME = override; + + const hermes = (await detectAgents(tmpHome)).find( + (agent) => agent.id === 'hermes' + ); + expect(hermes?.installed).toBe(true); + expect(hermes?.mcpRegistered).toBe(true); + expect(hermes?.configPaths[0]).toBe(path.join(override, 'config.yaml')); + }); + + it('sees every setup client as registered immediately after setup', async () => { + const ctx = createMcpContext({ + home: tmpHome, + cwd: tmpHome, + env: process.env, + auth: 'keyless', + }); + + for (const id of ALL_MCP_CLIENT_IDS) { + const result = await setupMcpClient(id, { rules: false, ctx }); + expect(result.mcpStatus, id).toBe('configured'); + } + + const agents = await detectAgents(tmpHome); + const doctorIds = [ + 'cursor', + 'claude-code', + 'vscode', + 'codex', + 'opencode', + 'hermes', + ] as const; + for (const id of doctorIds) { + const agent = agents.find((entry) => entry.id === id); + expect(agent?.installed, id).toBe(true); + expect(agent?.mcpRegistered, id).toBe(true); + } + }); }); describe('runChecks', () => { diff --git a/src/__tests__/commands/launch.test.ts b/src/__tests__/commands/launch.test.ts index 600ddcfc71..4e8fe0e8a1 100644 --- a/src/__tests__/commands/launch.test.ts +++ b/src/__tests__/commands/launch.test.ts @@ -317,6 +317,25 @@ describe('handleLaunchCommand', () => { expect(installSkillsForAgent).not.toHaveBeenCalled(); }); + it.each(['install', 'setup', 'config'] as const)( + 'rejects %s mode when both MCP and skills are skipped', + async (flag) => { + await expect( + handleLaunchCommand('opencode', { + [flag]: true, + skipMcp: true, + skipSkills: true, + }) + ).rejects.toThrow( + 'Install mode (--install, --setup, --config) cannot be combined with both --skip-mcp and --skip-skills.' + ); + + expect(installMcp).not.toHaveBeenCalled(); + expect(installSkillsForAgent).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + } + ); + it('configures Hermes MCP and skills, then launches Hermes Agent', async () => { await handleLaunchCommand('hermes'); diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 5dad063bc3..de427d938b 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -15,13 +15,30 @@ import { handleMakeDefaultCommand, handleSetupCommand, installMcp, - installHermesMcp, installOpenClawMcp, installSkillsForAgent, } from '../../commands/setup'; import { ALL_SKILL_REPOS } from '../../commands/skills-install'; import { configureWebDefaults } from '../../utils/web-defaults'; import { getApiKey } from '../../utils/config'; +import { + MCP_CLIENTS, + RULE_MARKER, + type McpClientId, +} from '../../utils/mcp-clients'; + +const MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; + +/** Where a given agent's global config lands on this platform. */ +function globalConfigPath(id: McpClientId, home: string): string { + return MCP_CLIENTS[id].globalConfigPath({ + home, + cwd: process.cwd(), + platform: process.platform, + env: process.env, + auth: 'keyless', + }); +} vi.mock('child_process', () => ({ execFileSync: vi.fn(), @@ -36,19 +53,87 @@ vi.mock('../../utils/config', () => ({ getApiKey: vi.fn(() => 'fc-test-key'), })); +vi.mock('@inquirer/prompts', () => ({ + checkbox: vi.fn(), + confirm: vi.fn(), +})); + describe('handleSetupCommand', () => { let originalHome: string | undefined; let originalApiKey: string | undefined; + let sandboxHome: string; + let originalPath: string | undefined; + let originalUserProfile: string | undefined; + let originalAppData: string | undefined; + let originalOpenclawWorkspace: string | undefined; + let originalOpenclawProfile: string | undefined; + let originalCodexHome: string | undefined; + let originalHermesHome: string | undefined; + let originalCwd: string; + let sandboxCwd: string; beforeEach(() => { vi.clearAllMocks(); + // clearAllMocks keeps implementations, so a test that makes a spawn throw + // would leak that behaviour into every test after it. + vi.mocked(execFileSync).mockReset(); + vi.mocked(execSync).mockReset(); vi.mocked(getApiKey).mockReturnValue('fc-test-key'); originalHome = process.env.HOME; originalApiKey = process.env.FIRECRAWL_API_KEY; delete process.env.FIRECRAWL_API_KEY; + // MCP setup writes real agent config files, so every test gets a throwaway + // home. Without this a test run would rewrite the developer's own editors. + sandboxHome = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-home-')); + process.env.HOME = sandboxHome; + // os.homedir() reads USERPROFILE on Windows, and app-support paths read + // APPDATA, so HOME alone would leave a Windows run writing the real profile. + originalUserProfile = process.env.USERPROFILE; + originalAppData = process.env.APPDATA; + process.env.USERPROFILE = sandboxHome; + process.env.APPDATA = path.join(sandboxHome, 'AppData', 'Roaming'); + // Project scope writes relative to cwd, so a run must not be able to drop + // config files into the repository itself. + originalCwd = process.cwd(); + sandboxCwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-cwd-')); + process.chdir(sandboxCwd); + // Launcher detection also looks on PATH, so pin it for the same reason. + originalPath = process.env.PATH; + process.env.PATH = ''; + originalOpenclawWorkspace = process.env.OPENCLAW_WORKSPACE_DIR; + originalOpenclawProfile = process.env.OPENCLAW_PROFILE; + originalCodexHome = process.env.CODEX_HOME; + originalHermesHome = process.env.HERMES_HOME; + delete process.env.OPENCLAW_WORKSPACE_DIR; + delete process.env.OPENCLAW_PROFILE; + delete process.env.CODEX_HOME; + delete process.env.HERMES_HOME; }); afterEach(() => { + process.chdir(originalCwd); + rmSync(sandboxCwd, { recursive: true, force: true }); + rmSync(sandboxHome, { recursive: true, force: true }); + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + if (originalOpenclawWorkspace === undefined) { + delete process.env.OPENCLAW_WORKSPACE_DIR; + } else { + process.env.OPENCLAW_WORKSPACE_DIR = originalOpenclawWorkspace; + } + if (originalOpenclawProfile === undefined) { + delete process.env.OPENCLAW_PROFILE; + } else { + process.env.OPENCLAW_PROFILE = originalOpenclawProfile; + } + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + if (originalHermesHome === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = originalHermesHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + if (originalAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = originalAppData; if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; if (originalApiKey === undefined) delete process.env.FIRECRAWL_API_KEY; @@ -123,6 +208,7 @@ describe('handleSetupCommand', () => { it('installs the default setup bundle with --yes', async () => { vi.mocked(getApiKey).mockReturnValue(undefined); + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); await handleSetupCommand(undefined, { yes: true }); @@ -134,22 +220,42 @@ describe('handleSetupCommand', () => { 'npx -y skills add firecrawl/skills --full-depth --global --all --yes', expect.objectContaining({ stdio: 'inherit' }) ); - expect(execFileSync).toHaveBeenCalledWith( - 'npx', - [ - '-y', - 'add-mcp@1.14.0', - 'https://mcp.firecrawl.dev/v2/mcp', - '--name', - 'firecrawl', - '--transport', - 'http', - '--global', - '--yes', - ], + expect( + JSON.parse( + readFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), 'utf-8') + ).mcpServers.firecrawl + ).toEqual({ url: MCP_URL }); + }); + + it('does not fail the --yes bundle when no coding agents are installed', async () => { + vi.mocked(getApiKey).mockReturnValue(undefined); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await handleSetupCommand(undefined, { yes: true }); + + expect(execSync).toHaveBeenCalledWith( + 'npx -y skills add firecrawl/cli --full-depth --global --all --yes', expect.objectContaining({ stdio: 'inherit' }) ); + expect(log.mock.calls.flat().join('\n')).toContain( + 'No coding agents detected' + ); + expect(existsSync(path.join(sandboxHome, '.cursor', 'mcp.json'))).toBe( + false + ); + }); + + it('does not throw when non-interactive MCP setup finds no agents', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await expect( + handleSetupCommand('mcp', { yes: true }) + ).resolves.toBeUndefined(); + expect(log.mock.calls.flat().join('\n')).toContain( + 'No coding agents detected' + ); }); + it('requires a subcommand for bare setup in non-interactive mode', async () => { const originalIsTty = process.stdin.isTTY; Object.defineProperty(process.stdin, 'isTTY', { @@ -196,185 +302,501 @@ describe('handleSetupCommand', () => { }); }); - it('fails closed before spawning when only a stored API key is available', async () => { - await expect( - handleSetupCommand('mcp', { + it('configures keyless when only a stored API key is available', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-stored-')); + process.env.HOME = home; + + try { + await handleSetupCommand('mcp', { agent: 'claude-code', global: true, yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); - expect(execFileSync).not.toHaveBeenCalled(); + }); + + // An agent cannot resolve a key that only lives in our credential + // store, so nothing is written rather than persisting a literal. + const config = readFileSync(path.join(home, '.claude.json'), 'utf-8'); + expect(JSON.parse(config).mcpServers.firecrawl).toEqual({ + type: 'http', + url: MCP_URL, + }); + expect(config).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); + it('can explicitly install keyless MCP without exposing a stored API key', async () => { - await installMcp({ - agent: 'claude-code', - global: true, - yes: true, - keyless: true, - }); + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-keyless-')); + process.env.HOME = home; + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - expect(execFileSync).toHaveBeenCalledWith( - 'npx', - [ - '-y', - 'add-mcp@1.14.0', - 'https://mcp.firecrawl.dev/v2/mcp', - '--name', - 'firecrawl', - '--transport', - 'http', - '--global', - '--agent', - 'claude-code', - '--yes', - ], - expect.objectContaining({ stdio: 'inherit' }) - ); - expect(vi.mocked(execFileSync).mock.calls.flat().join(' ')).not.toContain( - 'fc-test-key' - ); - }); - it('accepts a launch-scoped environment while keeping the stored key out of MCP config and argv', async () => { - await installMcp( - { + try { + await installMcp({ agent: 'claude-code', global: true, yes: true, - }, - { ...process.env, FIRECRAWL_API_KEY: 'fc-test-key' } - ); + keyless: true, + }); - 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(); + const config = readFileSync(path.join(home, '.claude.json'), 'utf-8'); + expect(config).not.toContain('fc-test-key'); + expect(config).not.toContain('Authorization'); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); + + it('accepts a launch-scoped environment while keeping the key out of MCP config', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-launch-env-')); + process.env.HOME = home; + + try { + await installMcp( + { agent: 'claude-code', global: true, yes: true }, + { ...process.env, FIRECRAWL_API_KEY: 'fc-test-key' } + ); + + const config = readFileSync(path.join(home, '.claude.json'), 'utf-8'); + expect(JSON.parse(config).mcpServers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${FIRECRAWL_API_KEY}', + }); + expect(config).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + it('normalizes launch aliases for environment-backed MCP setup', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-alias-')); + process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - await handleSetupCommand('mcp', { - agent: 'codex-app', - global: true, - yes: true, - }); + try { + await handleSetupCommand('mcp', { + agent: 'codex-app', + global: true, + yes: true, + }); - expect(execFileSync).toHaveBeenCalledWith( - 'codex', - [ - 'mcp', - 'add', - 'firecrawl', - '--url', - 'https://mcp.firecrawl.dev/v2/mcp', - '--bearer-token-env-var', - 'FIRECRAWL_API_KEY', - ], - expect.objectContaining({ stdio: 'inherit' }) - ); + const config = readFileSync( + path.join(home, '.codex', 'config.toml'), + 'utf-8' + ); + expect(config).toContain('[mcp_servers.firecrawl]'); + expect(config).toContain('bearer_token_env_var = "FIRECRAWL_API_KEY"'); + expect(config).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); + it.each([ - ['claude-code', 'Bearer ${FIRECRAWL_API_KEY}'], - ['vscode', 'Bearer ${env:FIRECRAWL_API_KEY}'], - ['cursor', 'Bearer ${env:FIRECRAWL_API_KEY}'], - ['opencode', 'Bearer {env:FIRECRAWL_API_KEY}'], - ])( + ['claude-code', 'claude', 'mcpServers', 'Bearer ${FIRECRAWL_API_KEY}'], + ['vscode', 'vscode', 'servers', 'Bearer ${env:FIRECRAWL_API_KEY}'], + ['cursor', 'cursor', 'mcpServers', 'Bearer ${env:FIRECRAWL_API_KEY}'], + ['opencode', 'opencode', 'mcp', 'Bearer {env:FIRECRAWL_API_KEY}'], + ] as const)( 'uses the %s environment reference when the API key came from the environment', - async (agent, header) => { + async (agent, id, serversKey, header) => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-envref-')); + process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + try { + await handleSetupCommand('mcp', { agent, global: true, yes: true }); + + const config = readFileSync(globalConfigPath(id, home), 'utf-8'); + expect(JSON.parse(config)[serversKey].firecrawl.headers).toEqual({ + Authorization: header, + }); + expect(config).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } + } + ); + + it('uses Codex native environment-backed bearer configuration', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-codex-env-')); + process.env.HOME = home; + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + + try { await handleSetupCommand('mcp', { - agent, + agent: 'codex', global: true, yes: true, }); - const args = vi.mocked(execFileSync).mock.calls[0]?.[1]; - expect(args).toContain(`Authorization: ${header}`); - expect(args?.join(' ')).not.toContain('Bearer fc-test-key'); + const config = readFileSync( + path.join(home, '.codex', 'config.toml'), + 'utf-8' + ); + expect(config).toContain('bearer_token_env_var = "FIRECRAWL_API_KEY"'); + expect(config).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); } - ); + }); - it('uses Codex native environment-backed bearer configuration', async () => { - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + it('installs MCP with the keyless hosted Firecrawl URL without credentials', async () => { + vi.mocked(getApiKey).mockReturnValue(undefined); + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-nokey-')); + process.env.HOME = home; + + try { + await handleSetupCommand('mcp', { + agent: 'claude-code', + global: true, + yes: true, + }); + + expect( + JSON.parse(readFileSync(path.join(home, '.claude.json'), 'utf-8')) + .mcpServers.firecrawl + ).toEqual({ type: 'http', url: MCP_URL }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it('offers launchers in the picker and configures Hermes by flag', async () => { + await handleSetupCommand('mcp', { clients: ['hermes'], yes: true }); + + expect( + readFileSync(path.join(sandboxHome, '.hermes', 'config.yaml'), 'utf-8') + ).toContain('firecrawl:'); + }); + + it('says so when rules are requested for an agent that has none', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); await handleSetupCommand('mcp', { - agent: 'codex', - global: true, + clients: ['hermes'], yes: true, + rules: true, }); - expect(execFileSync).toHaveBeenCalledWith( - 'codex', - [ - 'mcp', - 'add', - 'firecrawl', - '--url', - 'https://mcp.firecrawl.dev/v2/mcp', - '--bearer-token-env-var', - 'FIRECRAWL_API_KEY', - ], - expect.objectContaining({ stdio: 'inherit' }) - ); - expect(vi.mocked(execFileSync).mock.calls.flat(2).join(' ')).not.toContain( - 'fc-test-key' + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('not supported by this agent'); + expect(output).not.toContain('Rules skipped'); + }); + + it('does not claim rules are unsupported when they were not requested', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await handleSetupCommand('mcp', { clients: ['hermes'], yes: true }); + + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('Rules skipped'); + expect(output).not.toContain('not supported by this agent'); + }); + + it('lists only detected agents in the picker, already selected', async () => { + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); + mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + + const { checkbox, confirm } = await import('@inquirer/prompts'); + vi.mocked(checkbox).mockResolvedValue(['cursor']); + vi.mocked(confirm).mockResolvedValue(false); + + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: true, + }); + + try { + await handleSetupCommand('mcp', {}); + + expect(checkbox).toHaveBeenCalledOnce(); + expect(vi.mocked(checkbox).mock.calls[0]?.[0]).toMatchObject({ + choices: [ + { value: 'cursor', checked: true }, + { value: 'hermes', checked: true }, + ], + }); + expect(existsSync(path.join(sandboxHome, '.cursor', 'mcp.json'))).toBe( + true + ); + expect(existsSync(path.join(sandboxHome, '.hermes', 'config.yaml'))).toBe( + false + ); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: originalIsTTY, + }); + } + }); + + it('detects an installed launcher so the picker can pre-select it', async () => { + mkdirSync(path.join(sandboxHome, '.openclaw'), { recursive: true }); + + const { detectMcpLaunchers } = await import('../../utils/mcp-clients'); + expect( + detectMcpLaunchers({ + home: sandboxHome, + cwd: process.cwd(), + platform: process.platform, + env: { PATH: '' }, + auth: 'keyless', + }) + ).toContain('openclaw'); + }); + + it('detects Hermes as a config-file client, not a launcher', async () => { + mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + + const { detectMcpClients, detectMcpLaunchers } = + await import('../../utils/mcp-clients'); + const ctx = { + home: sandboxHome, + cwd: process.cwd(), + platform: process.platform, + // Hermes is matched on its config directory alone. An unrelated + // JavaScript engine of the same name ships on many machines. + env: { PATH: '' }, + auth: 'keyless' as const, + }; + + expect(await detectMcpClients(ctx)).toContain('hermes'); + expect(detectMcpLaunchers(ctx)).not.toContain('hermes'); + }); + + it('keeps a failing launcher from taking down the other agents', async () => { + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); + // OpenClaw shells out; a missing binary must stay scoped to OpenClaw. + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('ENOENT'); + }); + + // The launcher failing is reported, not swallowed, but it does not stop + // the agents beside it from being configured. + await expect( + handleSetupCommand('mcp', { + clients: ['cursor', 'openclaw'], + yes: true, + }) + ).rejects.toThrow(/OpenClaw/); + + expect( + JSON.parse( + readFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), 'utf-8') + ).mcpServers.firecrawl.url + ).toBe(MCP_URL); + }); + + it('fails the run when only some of the chosen agents worked', async () => { + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); + mkdirSync(path.join(sandboxHome, '.claude'), { recursive: true }); + writeFileSync(globalConfigPath('cursor', sandboxHome), '{ oops'); + + // Claude is still configured; the command reports that Cursor was not. + await expect( + handleSetupCommand('mcp', { + clients: ['cursor', 'claude'], + yes: true, + } as never) + ).rejects.toThrow(/Cursor/); + + expect(existsSync(path.join(sandboxHome, '.claude.json'))).toBe(true); + }); + + it('asks about rules for --agent all just like a single agent', async () => { + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); + const { confirm } = await import('@inquirer/prompts'); + vi.mocked(confirm).mockResolvedValue(true); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: true, + }); + + try { + // Naming the agents skips the picker on its own; it must not also + // decide the rules question on the user's behalf. + await handleSetupCommand('mcp', { agent: 'all' }); + + expect(confirm).toHaveBeenCalledOnce(); + expect(log.mock.calls.flat().join('\n')).toContain('Customize → Rules'); + expect( + existsSync(path.join(sandboxHome, '.cursor', 'rules', 'firecrawl.mdc')) + ).toBe(false); + } finally { + log.mockRestore(); + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: originalIsTTY, + }); + } + }); + + it('tells the user how to add a Cursor User Rule instead of writing one', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await handleSetupCommand('mcp', { + clients: ['cursor'], + yes: true, + rules: true, + }); + + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('not supported by this agent'); + expect(output).toContain('Customize → Rules'); + expect( + existsSync(path.join(sandboxHome, '.cursor', 'rules', 'firecrawl.mdc')) + ).toBe(false); + }); + + it('writes VS Code instructions to ~/.copilot/instructions', async () => { + await handleSetupCommand('mcp', { + clients: ['vscode'], + yes: true, + rules: true, + }); + + const written = readFileSync( + path.join( + sandboxHome, + '.copilot', + 'instructions', + 'firecrawl.instructions.md' + ), + 'utf-8' ); + expect(written).toContain("applyTo: '**'"); }); - it('installs MCP with the keyless hosted Firecrawl URL without credentials', async () => { - vi.mocked(getApiKey).mockReturnValue(undefined); + it('writes Codex config and rules under CODEX_HOME', async () => { + const override = path.join(sandboxHome, 'codex-override'); + process.env.CODEX_HOME = override; await handleSetupCommand('mcp', { - agent: 'claude-code', + clients: ['codex'], + yes: true, + rules: true, + }); + + expect(existsSync(path.join(override, 'config.toml'))).toBe(true); + expect(existsSync(path.join(override, 'AGENTS.md'))).toBe(true); + expect(existsSync(path.join(sandboxHome, '.codex'))).toBe(false); + }); + + it('writes Hermes config under HERMES_HOME', async () => { + const override = path.join(sandboxHome, 'hermes-override'); + process.env.HERMES_HOME = override; + + await handleSetupCommand('mcp', { + clients: ['hermes'], + yes: true, + }); + + expect(existsSync(path.join(override, 'config.yaml'))).toBe(true); + expect(existsSync(path.join(sandboxHome, '.hermes'))).toBe(false); + }); + + it('surfaces total failure even in quiet mode', async () => { + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); + writeFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), '{ broken'); + + // init and launch both pass quiet, and must not report success when + // nothing was written. + await expect( + installMcp({ clients: ['cursor'], yes: true, quiet: true, keyless: true }) + ).rejects.toThrow('Failed to configure Firecrawl MCP'); + }); + + it('reports a partial run rather than throwing in quiet mode', async () => { + // OpenClaw is registered through its own CLI, which is not on PATH here. + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('openclaw missing'); + }); + + const configured = await installMcp({ + clients: ['cursor', 'openclaw'], + yes: true, + quiet: true, + keyless: true, + }); + + // Cursor landed, so this stays a success for the run as a whole, and a + // quiet caller has to learn about OpenClaw from the return value. + expect(configured).toBe(false); + expect(existsSync(globalConfigPath('cursor', sandboxHome))).toBe(true); + }); + + it('reports a clean run when every agent was configured', async () => { + await expect( + installMcp({ clients: ['cursor'], yes: true, quiet: true, keyless: true }) + ).resolves.toBe(true); + }); + + it('configures every client with --agent all, detected or not', async () => { + await handleSetupCommand('mcp', { + agent: 'all', global: true, yes: true, + keyless: true, }); - expect(execFileSync).toHaveBeenCalledWith( - 'npx', - [ - '-y', - 'add-mcp@1.14.0', - 'https://mcp.firecrawl.dev/v2/mcp', - '--name', - 'firecrawl', - '--transport', - 'http', - '--global', - '--agent', - 'claude-code', - '--yes', - ], - expect.objectContaining({ stdio: 'inherit' }) + for (const id of [ + 'claude', + 'cursor', + 'codex', + 'vscode', + 'opencode', + ] as const) { + expect(existsSync(globalConfigPath(id, sandboxHome))).toBe(true); + } + }); + + it('skips MCP for a skills-only agent instead of failing the run', async () => { + // Skills already installed by this point in `setup --yes --agent windsurf`. + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await expect( + handleSetupCommand('mcp', { agent: 'windsurf', yes: true }) + ).resolves.toBeUndefined(); + + expect(log.mock.calls.flat().join(' ')).toContain( + 'https://mcp.firecrawl.dev/v2/mcp' ); }); - it('rejects a stored key before writing Hermes MCP config', async () => { + it('still rejects an agent name nothing supports', async () => { + await expect( + handleSetupCommand('mcp', { agent: 'not-an-agent', yes: true }) + ).rejects.toThrow('Unknown agent'); + }); + + it('falls back to keyless Hermes MCP when only a stored key exists', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-hermes-test-')); process.env.HOME = home; const configPath = path.join(home, '.hermes', 'config.yaml'); mkdirSync(path.dirname(configPath), { recursive: true }); - const originalConfig = - 'theme: dark\nmcp_servers:\n existing:\n url: https://example.com/mcp\n'; - writeFileSync(configPath, originalConfig, { mode: 0o600 }); + writeFileSync( + configPath, + 'theme: dark\nmcp_servers:\n existing:\n url: https://example.com/mcp\n', + { mode: 0o600 } + ); try { - await expect( - handleSetupCommand('mcp', { - agent: 'hermes', - global: true, - yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); + await handleSetupCommand('mcp', { + agent: 'hermes', + global: true, + yes: true, + }); const config = readFileSync(configPath, 'utf-8'); - expect(config).toBe(originalConfig); expect(config).toContain('theme: dark'); expect(config).toContain('existing:'); - expect(config).toContain('mcp_servers:'); - expect(config).not.toContain('firecrawl:'); + expect(config).toContain('firecrawl:'); + expect(config).toContain(MCP_URL); + expect(config).not.toContain('Authorization'); expect(config).not.toContain('fc-test-key'); expect(execFileSync).not.toHaveBeenCalled(); if (process.platform !== 'win32') { @@ -393,7 +815,7 @@ describe('handleSetupCommand', () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; try { - await installHermesMcp(); + await installMcp({ agent: 'hermes' }); const config = readFileSync( path.join(home, '.hermes', 'config.yaml'), @@ -427,12 +849,34 @@ describe('handleSetupCommand', () => { } }); + it('suppresses Hermes installer logs in quiet mode', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await installMcp({ agent: 'hermes', quiet: true, keyless: true }); + expect(log.mock.calls.flat().join('\n')).not.toContain( + 'Hermes Agent MCP configured' + ); + } finally { + log.mockRestore(); + } + }); + it('rejects a stored key before invoking the OpenClaw CLI', async () => { await expect(installOpenClawMcp()).rejects.toThrow( 'Export FIRECRAWL_API_KEY' ); expect(execFileSync).not.toHaveBeenCalled(); }); + + it('falls back to keyless OpenClaw MCP when only a stored key exists', async () => { + await installMcp({ agent: 'openclaw' }); + + const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; + expect(config).toContain(MCP_URL); + expect(config).not.toContain('Authorization'); + expect(config).not.toContain('fc-test-key'); + }); it('uses OpenClaw environment expansion instead of persisting an env-backed key', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; @@ -463,118 +907,361 @@ describe('handleSetupCommand', () => { ); }); - it('rejects stored credentials before configuring any launch integration', async () => { + it('falls back to keyless for a stored key on every launch integration', async () => { + // One rule everywhere: a stored key is never written, and --agent all + // configures keyless rather than aborting the way it used to. + await handleSetupCommand('mcp', { agent: 'all', yes: true }); + + const hermes = readFileSync( + path.join(sandboxHome, '.hermes', 'config.yaml'), + 'utf-8' + ); + expect(hermes).toContain('firecrawl:'); + expect(hermes).not.toContain('fc-test-key'); + expect( + readFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), 'utf-8') + ).not.toContain('fc-test-key'); + }); + + it('treats --agent launchers as the launchers, not as every agent', async () => { + await handleSetupCommand('mcp', { agent: 'launchers', yes: true }); + + // OpenClaw is the only launcher; it is configured through its own CLI. + const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; + expect(config).toContain(MCP_URL); + expect(existsSync(globalConfigPath('cursor', sandboxHome))).toBe(false); + expect(existsSync(path.join(sandboxHome, '.hermes', 'config.yaml'))).toBe( + false + ); + }); + it('fences the rule into an existing OpenClaw workspace AGENTS.md', async () => { + const workspace = path.join(sandboxHome, '.openclaw', 'workspace'); + mkdirSync(workspace, { recursive: true }); + const agentsFile = path.join(workspace, 'AGENTS.md'); + writeFileSync(agentsFile, '# My workspace\n\nKeep this text.\n'); + + await handleSetupCommand('mcp', { + clients: ['openclaw'], + yes: true, + rules: true, + } as never); + + const written = readFileSync(agentsFile, 'utf-8'); + expect(written).toContain('# My workspace'); + expect(written).toContain('Keep this text.'); + expect(written).toContain('firecrawl_search'); + + // A rerun replaces the fenced section rather than adding a second copy. + await handleSetupCommand('mcp', { + clients: ['openclaw'], + yes: true, + rules: true, + } as never); + const rerun = readFileSync(agentsFile, 'utf-8'); + expect(rerun.match(new RegExp(RULE_MARKER, 'g'))).toHaveLength(2); + expect(rerun).toBe(written); + }); + + it('does not write the OpenClaw rule when MCP registration fails', async () => { + const workspace = path.join(sandboxHome, '.openclaw', 'workspace'); + mkdirSync(workspace, { recursive: true }); + const agentsFile = path.join(workspace, 'AGENTS.md'); + writeFileSync(agentsFile, '# Keep\n'); + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('openclaw missing'); + }); + await expect( handleSetupCommand('mcp', { - agent: 'all', - global: true, + clients: ['openclaw'], yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); - expect(execFileSync).not.toHaveBeenCalled(); + rules: true, + } as never) + ).rejects.toThrow(/OpenClaw/); + + expect(readFileSync(agentsFile, 'utf-8')).toBe('# Keep\n'); }); - it('uses each client native environment binding with --agent all', async () => { - const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-env-test-')); - process.env.HOME = home; - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + + it('writes the OpenClaw rule to the workspace the CLI reports', async () => { + const moved = path.join(sandboxHome, 'cli-workspace'); + mkdirSync(moved, { recursive: true }); + writeFileSync(path.join(moved, 'AGENTS.md'), '# CLI\n'); + const defaultAgents = path.join( + sandboxHome, + '.openclaw', + 'workspace', + 'AGENTS.md' + ); + mkdirSync(path.dirname(defaultAgents), { recursive: true }); + writeFileSync(defaultAgents, '# Default\n'); + + vi.mocked(execFileSync).mockImplementation((_cmd, args) => { + const haystack = Array.isArray(args) ? args.join(' ') : String(args); + if (haystack.includes('config') && haystack.includes('get')) { + return JSON.stringify(moved); + } + return ''; + }); + + await handleSetupCommand('mcp', { + clients: ['openclaw'], + yes: true, + rules: true, + } as never); + + expect(readFileSync(path.join(moved, 'AGENTS.md'), 'utf-8')).toContain( + 'firecrawl_search' + ); + expect(readFileSync(defaultAgents, 'utf-8')).toBe('# Default\n'); + }); + + it('expands a ~/ workspace reported by the CLI against this home', async () => { + const moved = path.join(sandboxHome, 'tilde-workspace'); + mkdirSync(moved, { recursive: true }); + writeFileSync(path.join(moved, 'AGENTS.md'), '# Tilde\n'); + + vi.mocked(execFileSync).mockImplementation((_cmd, args) => { + const haystack = Array.isArray(args) ? args.join(' ') : String(args); + return haystack.includes('config') && haystack.includes('get') + ? JSON.stringify('~/tilde-workspace') + : ''; + }); + + await handleSetupCommand('mcp', { + clients: ['openclaw'], + yes: true, + rules: true, + } as never); + + expect(readFileSync(path.join(moved, 'AGENTS.md'), 'utf-8')).toContain( + 'firecrawl_search' + ); + }); + + it('does not read a ~other workspace as a path under this home', async () => { + // `~other/ws` is another account's home to a shell. Treating the tilde as + // ours would write the rule into $HOME/other/ws instead. + const lookalike = path.join(sandboxHome, 'other', 'ws'); + mkdirSync(lookalike, { recursive: true }); + writeFileSync(path.join(lookalike, 'AGENTS.md'), '# Someone else\n'); + + vi.mocked(execFileSync).mockImplementation((_cmd, args) => { + const haystack = Array.isArray(args) ? args.join(' ') : String(args); + return haystack.includes('config') && haystack.includes('get') + ? JSON.stringify('~other/ws') + : ''; + }); + + await handleSetupCommand('mcp', { + clients: ['openclaw'], + yes: true, + rules: true, + } as never); + + expect(readFileSync(path.join(lookalike, 'AGENTS.md'), 'utf-8')).toBe( + '# Someone else\n' + ); + }); + + it('leaves the OpenClaw rule alone until its workspace exists', async () => { + await handleSetupCommand('mcp', { + clients: ['openclaw'], + yes: true, + rules: true, + } as never); + + // Creating AGENTS.md before OpenClaw bootstraps it would cost the user the + // instructions the launcher seeds that file with. + expect( + existsSync(path.join(sandboxHome, '.openclaw', 'workspace', 'AGENTS.md')) + ).toBe(false); + }); + + it('follows OPENCLAW_WORKSPACE_DIR when the workspace has moved', async () => { + const moved = path.join(sandboxHome, 'elsewhere'); + mkdirSync(moved, { recursive: true }); + writeFileSync(path.join(moved, 'AGENTS.md'), '# Moved\n'); + process.env.OPENCLAW_WORKSPACE_DIR = moved; try { await handleSetupCommand('mcp', { - agent: 'all', - global: true, + clients: ['openclaw'], yes: true, - }); + rules: true, + } as never); - const calls = vi.mocked(execFileSync).mock.calls; - const serialized = calls.map((call) => (call[1] as string[]).join(' ')); - expect(serialized).toEqual( - expect.arrayContaining([ - expect.stringContaining('claude-code --yes'), - expect.stringContaining( - 'Authorization: Bearer ${env:FIRECRAWL_API_KEY}' - ), - expect.stringContaining('--bearer-token-env-var FIRECRAWL_API_KEY'), - expect.stringContaining( - 'Authorization: Bearer {env:FIRECRAWL_API_KEY}' - ), - expect.stringContaining('Authorization: Bearer ${FIRECRAWL_API_KEY}'), - ]) + expect(readFileSync(path.join(moved, 'AGENTS.md'), 'utf-8')).toContain( + 'firecrawl_search' ); - expect(calls.flat(2).join(' ')).not.toContain('Bearer fc-test-key'); - expect( - readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') - ).toContain('Authorization: Bearer ${FIRECRAWL_API_KEY}'); } finally { - rmSync(home, { recursive: true, force: true }); + delete process.env.OPENCLAW_WORKSPACE_DIR; } }); - it('rejects authenticated --agent all project setup before changing any client', async () => { - const home = mkdtempSync( - path.join(os.tmpdir(), 'firecrawl-all-project-preflight-') + it('points every agent at the sign-in endpoint with --oauth', async () => { + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + for (const dir of ['.claude', '.cursor', '.codex', '.hermes']) { + mkdirSync(path.join(sandboxHome, dir), { recursive: true }); + } + + await handleSetupCommand('mcp', { oauth: true, yes: true } as never); + + const claude = readFileSync( + path.join(sandboxHome, '.claude.json'), + 'utf-8' ); + expect(claude).toContain('/v2/mcp-oauth'); + // Sign-in replaces the credential rather than travelling beside it. + expect(claude).not.toContain('Authorization'); + expect(claude).not.toContain('fc-test-key'); + + // Codex takes a bare URL; its sign-in is a separate login command. + expect( + readFileSync(path.join(sandboxHome, '.codex', 'config.toml'), 'utf-8') + ).toContain('/v2/mcp-oauth'); + }); + + it('arms the sign-in flow for agents that need more than a URL', async () => { + mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + + await handleSetupCommand('mcp', { + clients: ['hermes', 'openclaw'], + oauth: true, + yes: true, + } as never); + + // Hermes only starts the flow when the entry opts in. + expect( + readFileSync(path.join(sandboxHome, '.hermes', 'config.yaml'), 'utf-8') + ).toContain('auth: oauth'); + + // OpenClaw ignores a static header once this is set, and its login + // command only runs for servers configured with it. + const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; + expect(JSON.parse(config)).toMatchObject({ + url: `${MCP_URL}-oauth`, + auth: 'oauth', + }); + }); + + it('keeps credential configuration off the sign-in endpoint', async () => { + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + + // Called directly with sign-in but without keyless, the shape a caller + // outside this file could reach. + await installOpenClawMcp(process.env, false, true, true); + + const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; + expect(JSON.parse(config)).toEqual({ + url: `${MCP_URL}-oauth`, + transport: 'streamable-http', + auth: 'oauth', + }); + expect(config).not.toContain('Authorization'); + }); + + it('refuses to combine sign-in with keyless', async () => { + await expect( + handleSetupCommand('mcp', { + clients: ['cursor'], + oauth: true, + keyless: true, + yes: true, + } as never) + ).rejects.toThrow(/either --oauth or --keyless/); + }); + + it('uses each client native environment binding with --agent all', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-env-test-')); + // Make several agents detectable so --agent all has editors to configure. + for (const dir of ['.claude', '.cursor', '.codex']) { + mkdirSync(path.join(home, dir), { recursive: true }); + } process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; try { - await expect( - handleSetupCommand('mcp', { - agent: 'all', - project: true, - yes: true, - }) - ).rejects.toThrow( - 'Authenticated --agent all setup does not support --project' - ); + await handleSetupCommand('mcp', { + agent: 'all', + global: true, + yes: true, + }); - expect(execFileSync).not.toHaveBeenCalled(); - expect(execSync).not.toHaveBeenCalled(); - expect(existsSync(path.join(home, '.hermes', 'config.yaml'))).toBe(false); + const claude = readFileSync(path.join(home, '.claude.json'), 'utf-8'); + const cursor = readFileSync( + path.join(home, '.cursor', 'mcp.json'), + 'utf-8' + ); + const codex = readFileSync( + path.join(home, '.codex', 'config.toml'), + 'utf-8' + ); + expect(JSON.parse(claude).mcpServers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${FIRECRAWL_API_KEY}', + }); + expect(JSON.parse(cursor).mcpServers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', + }); + expect(codex).toContain('bearer_token_env_var = "FIRECRAWL_API_KEY"'); + expect(`${claude}${cursor}${codex}`).not.toContain('fc-test-key'); + expect( + readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') + ).toContain('Authorization: Bearer ${FIRECRAWL_API_KEY}'); } finally { rmSync(home, { recursive: true, force: true }); } }); - it('keeps keyless --agent all project setup available', async () => { - const home = mkdtempSync( - path.join(os.tmpdir(), 'firecrawl-all-project-keyless-') - ); + it('keeps keyless --agent all setup available', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-keyless-')); process.env.HOME = home; vi.mocked(getApiKey).mockReturnValue(undefined); try { await handleSetupCommand('mcp', { agent: 'all', - project: true, yes: true, }); - const addMcpCalls = vi - .mocked(execFileSync) - .mock.calls.filter(([, args]) => - (args as string[])?.includes('add-mcp@1.14.0') - ); - expect(addMcpCalls).toHaveLength(5); - expect(addMcpCalls.flat(2)).not.toContain('--global'); expect( readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') - ).toContain('https://mcp.firecrawl.dev/v2/mcp'); + ).toContain(MCP_URL); } finally { rmSync(home, { recursive: true, force: true }); } }); - it('requires a client selection for no-agent environment-backed setup', async () => { + it('configures every detected agent when no --agent is given', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-noagent-')); + mkdirSync(path.join(home, '.cursor'), { recursive: true }); + mkdirSync(path.join(home, '.claude'), { recursive: true }); + process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - await expect( - handleSetupCommand('mcp', { global: true, yes: true }) - ).rejects.toThrow('requires --agent'); - expect(execFileSync).not.toHaveBeenCalled(); + try { + // Knowing each selected agent means each gets its own native syntax, + // so no explicit --agent is required. + await handleSetupCommand('mcp', { global: true, yes: true }); + + expect( + JSON.parse( + readFileSync(path.join(home, '.cursor', 'mcp.json'), 'utf-8') + ).mcpServers.firecrawl.headers + ).toEqual({ Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}' }); + expect( + JSON.parse(readFileSync(path.join(home, '.claude.json'), 'utf-8')) + .mcpServers.firecrawl.headers + ).toEqual({ Authorization: 'Bearer ${FIRECRAWL_API_KEY}' }); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); - it('rejects an environment-backed key for an unknown client instead of persisting it', async () => { - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + it.each([ + ['an environment-backed key', true], + ['a stored key', false], + ])('rejects an unknown client with %s', async (_label, fromEnv) => { + if (fromEnv) process.env.FIRECRAWL_API_KEY = 'fc-test-key'; await expect( handleSetupCommand('mcp', { @@ -582,45 +1269,54 @@ describe('handleSetupCommand', () => { global: true, yes: true, }) - ).rejects.toThrow('does not have a verified environment-variable syntax'); + ).rejects.toThrow('Unknown agent'); expect(execFileSync).not.toHaveBeenCalled(); }); - it('rejects a stored key for an unknown client before spawning', async () => { - await expect( - handleSetupCommand('mcp', { - agent: 'future-client', - global: true, - yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); - expect(execFileSync).not.toHaveBeenCalled(); - }); - it('never includes environment-backed credentials in generated URLs or normal output', async () => { + it('never includes environment-backed credentials in config or normal output', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-no-leak-')); + process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - await handleSetupCommand('mcp', { - agent: 'claude-code', - global: true, - yes: true, - }); + try { + await handleSetupCommand('mcp', { + agent: 'claude-code', + global: true, + yes: true, + }); - const args = vi.mocked(execFileSync).mock.calls[0]?.[1]; - expect(args).toContain('https://mcp.firecrawl.dev/v2/mcp'); - expect(args?.join(' ')).not.toContain('fc-test-key'); - expect(log.mock.calls.flat().join(' ')).not.toContain('fc-test-key'); + const config = readFileSync(path.join(home, '.claude.json'), 'utf-8'); + expect(config).toContain(MCP_URL); + expect(config).not.toContain('fc-test-key'); + expect(log.mock.calls.flat().join(' ')).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); - it('never places a stored API key in subprocess argv', async () => { - await expect( - handleSetupCommand('mcp', { + + it('never places a stored API key in config or argv', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-stored-argv-')); + process.env.HOME = home; + + try { + await handleSetupCommand('mcp', { agent: 'claude-code', global: true, yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); - expect(execFileSync).not.toHaveBeenCalled(); + }); + + expect( + readFileSync(path.join(home, '.claude.json'), 'utf-8') + ).not.toContain('fc-test-key'); + expect( + vi.mocked(execFileSync).mock.calls.flat(2).join(' ') + ).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); + it('does not print a stored OpenClaw credential when setup is rejected', async () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -630,106 +1326,59 @@ describe('handleSetupCommand', () => { expect(log.mock.calls.flat().join(' ')).not.toContain('fc-test-key'); }); - it('rejects stored credentials containing hostile characters without spawning or printing them', async () => { + + it('never persists or prints stored credentials containing hostile characters', async () => { const hostileKey = 'fc-$(touch /tmp/firecrawl-pwned)`echo bad`"\\n$HOME'; vi.mocked(getApiKey).mockReturnValue(hostileKey); + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-hostile-')); + process.env.HOME = home; const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); const error = vi .spyOn(console, 'error') .mockImplementation(() => undefined); - await expect( - handleSetupCommand('mcp', { + try { + await handleSetupCommand('mcp', { agent: 'claude-code', global: true, yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); - - expect(execFileSync).not.toHaveBeenCalled(); - expect(execSync).not.toHaveBeenCalled(); - expect(log.mock.calls.flat().join(' ')).not.toContain(hostileKey); - expect(error.mock.calls.flat().join(' ')).not.toContain(hostileKey); - }); - // --- Scope: project and global are mutually exclusive --- - - it('rejects conflicting MCP scope flags', async () => { - await expect( - handleSetupCommand('mcp', { - agent: 'claude-code', - global: true, - project: true, - }) - ).rejects.toThrow('Choose either --global or --project'); - expect(execFileSync).not.toHaveBeenCalled(); - }); - - it('keeps project scope for an environment-backed credential', async () => { - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - - await handleSetupCommand('mcp', { - agent: 'cursor', - project: true, - yes: true, - }); - - const args = vi.mocked(execFileSync).mock.calls[0]?.[1] as string[]; - expect(args).toContain('Authorization: Bearer ${env:FIRECRAWL_API_KEY}'); - expect(args).not.toContain('--global'); - expect(args.join(' ')).not.toContain('Bearer fc-test-key'); - }); - - it('does not force global MCP scope in the default bundle when --project is set', async () => { - vi.mocked(getApiKey).mockReturnValue(undefined); - - await handleSetupCommand(undefined, { - agent: 'cursor', - project: true, - yes: true, - }); - - const mcpCall = vi - .mocked(execFileSync) - .mock.calls.find(([command]) => command === 'npx'); - expect(mcpCall?.[1]).not.toContain('--global'); - }); - - it('does not force global when using an environment reference (no raw key in header)', async () => { - // Env-backed cursor uses ${env:FIRECRAWL_API_KEY}, not the literal secret, - // so project scope is safe and must not be silently overridden. - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - - await handleSetupCommand('mcp', { - agent: 'cursor', - yes: true, - }); + }); - const args = vi.mocked(execFileSync).mock.calls[0]?.[1] as string[]; - expect(args.join(' ')).not.toContain('Bearer fc-test-key'); - expect(args).toContain('Authorization: Bearer ${env:FIRECRAWL_API_KEY}'); - expect(args).not.toContain('--global'); + expect( + readFileSync(path.join(home, '.claude.json'), 'utf-8') + ).not.toContain(hostileKey); + expect(execFileSync).not.toHaveBeenCalled(); + expect(execSync).not.toHaveBeenCalled(); + expect(log.mock.calls.flat().join(' ')).not.toContain(hostileKey); + expect(error.mock.calls.flat().join(' ')).not.toContain(hostileKey); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); - it('does not force global for the keyless (unauthenticated) setup', async () => { + it('writes MCP into global agent config', async () => { vi.mocked(getApiKey).mockReturnValue(undefined); + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-global-')); + process.env.HOME = home; - await handleSetupCommand('mcp', { - agent: 'claude-code', - yes: true, - }); + try { + await handleSetupCommand('mcp', { agent: 'claude-code', yes: true }); - const args = vi.mocked(execFileSync).mock.calls[0]?.[1] as string[]; - expect(args.join(' ')).not.toContain('--header'); - expect(args).not.toContain('--global'); + const config = readFileSync(path.join(home, '.claude.json'), 'utf-8'); + expect(config).not.toContain('Authorization'); + expect(config).toContain(MCP_URL); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); // --- Windows: launch .cmd/.exe shims correctly (execFileSync cannot) --- - it('launches the npx.cmd shim via the shell on win32 with cmd-escaped args', async () => { + it('launches a .cmd shim via the shell on win32 with cmd-escaped args', async () => { const root = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-win-')); const bin = path.join(root, 'Program Files', 'nodejs'); mkdirSync(bin, { recursive: true }); - writeFileSync(path.join(bin, 'npx.CMD'), '@exit /b 0\r\n'); + writeFileSync(path.join(bin, 'openclaw.CMD'), '@exit /b 0\r\n'); const originalPlatform = Object.getOwnPropertyDescriptor( process, 'platform' @@ -748,7 +1397,7 @@ describe('handleSetupCommand', () => { try { await handleSetupCommand('mcp', { - agent: 'claude-code', + agent: 'openclaw', global: true, yes: true, }); @@ -761,11 +1410,11 @@ describe('handleSetupCommand', () => { expect(command).toBe('cmd.exe'); expect(passthruArgs.slice(0, 3)).toEqual(['/d', '/s', '/c']); expect(opts?.windowsVerbatimArguments).toBe(true); - expect(passthruArgs[3]).toContain(`^\"${path.join(bin, 'npx.CMD')}^\"`); - expect(passthruArgs[3]).toContain('add-mcp@1.14.0'); expect(passthruArgs[3]).toContain( - '^"Authorization: Bearer ${FIRECRAWL_API_KEY}^"' + `^\"${path.join(bin, 'openclaw.CMD')}^\"` ); + expect(passthruArgs[3]).toContain('Bearer ${FIRECRAWL_API_KEY}'); + expect(passthruArgs[3]).not.toContain('fc-test-key'); } finally { if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform); @@ -779,10 +1428,10 @@ describe('handleSetupCommand', () => { } }); - it('launches a native Codex executable directly on win32', async () => { + it('launches a native executable directly on win32', async () => { const bin = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-win-bin-')); - const codexExe = path.join(bin, 'codex.EXE'); - writeFileSync(codexExe, ''); + const openclawExe = path.join(bin, 'openclaw.EXE'); + writeFileSync(openclawExe, ''); const originalPlatform = Object.getOwnPropertyDescriptor( process, 'platform' @@ -799,7 +1448,7 @@ describe('handleSetupCommand', () => { try { await handleSetupCommand('mcp', { - agent: 'codex', + agent: 'openclaw', global: true, yes: true, }); @@ -808,8 +1457,8 @@ describe('handleSetupCommand', () => { const command = call?.[0] as string; const args = call?.[1] as string[]; const opts = call?.[2] as { windowsVerbatimArguments?: boolean }; - expect(command).toBe(codexExe); - expect(args).toContain('--bearer-token-env-var'); + expect(command).toBe(openclawExe); + expect(args.join(' ')).toContain('Bearer ${FIRECRAWL_API_KEY}'); expect(opts?.windowsVerbatimArguments).toBeUndefined(); } finally { if (originalPlatform) @@ -824,15 +1473,15 @@ describe('handleSetupCommand', () => { it('still spawns bare argv with no shell on non-win32', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - // Sanity: the pre-existing POSIX path is unchanged (argv-safe, no shell). + // Sanity: the POSIX path stays argv-safe with no shell interpolation. await handleSetupCommand('mcp', { - agent: 'claude-code', + agent: 'openclaw', global: true, yes: true, }); const call = vi.mocked(execFileSync).mock.calls[0]; - expect(call?.[0]).toBe('npx'); + expect(call?.[0]).toBe('openclaw'); expect( Array.isArray(call?.[1]) && (call?.[1] as string[]).length ).toBeGreaterThan(0); diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts new file mode 100644 index 0000000000..a51d658095 --- /dev/null +++ b/src/__tests__/utils/mcp-install.test.ts @@ -0,0 +1,929 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import os from 'os'; +import path from 'path'; +import { + ALL_MCP_CLIENT_IDS, + detectMcpClients, + detectMcpLaunchers, + resolveMcpClientId, + type McpAuthMode, + type McpContext, +} from '../../utils/mcp-clients'; +import { parseTOML } from 'toml-eslint-parser'; +import { parse as parseYaml } from 'yaml'; +import { + appendRuleSection, + setupMcpClient, + tomlHasServer, + upsertTomlServer, + upsertYamlServer, + writeJsonServerEntry, +} from '../../utils/mcp-install'; + +const MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; + +describe('mcp install', () => { + let root: string; + let ctx: McpContext; + + beforeEach(() => { + root = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-mcp-engine-')); + mkdirSync(path.join(root, 'proj'), { recursive: true }); + ctx = { + home: path.join(root, 'home'), + cwd: path.join(root, 'proj'), + platform: 'darwin', + env: {}, + auth: 'keyless', + }; + mkdirSync(ctx.home, { recursive: true }); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const read = (...parts: string[]) => + readFileSync(path.join(...parts), 'utf-8'); + + describe('writeJsonServerEntry', () => { + it('creates the file and its parent directory when missing', async () => { + const file = path.join(root, 'nested', 'mcp.json'); + + const { status } = await writeJsonServerEntry(file, 'mcpServers', 'fc', { + url: MCP_URL, + }); + + expect(status).toBe('configured'); + expect(JSON.parse(read(file))).toEqual({ + mcpServers: { fc: { url: MCP_URL } }, + }); + }); + + it('preserves comments and unrelated keys in an existing JSONC config', async () => { + const file = path.join(root, 'settings.json'); + writeFileSync( + file, + [ + '// editor settings', + '{', + ' "theme": "One Dark",', + ' // keep me', + ' "buffer_font_size": 15,', + ' "servers": { "other": { "url": "https://example.com" } }', + '}', + '', + ].join('\n') + ); + + await writeJsonServerEntry(file, 'servers', 'fc', { url: MCP_URL }); + + const result = read(file); + expect(result).toContain('// editor settings'); + expect(result).toContain('// keep me'); + expect(result).toContain('"theme": "One Dark"'); + expect(result).toContain('"other"'); + expect(result).toContain(MCP_URL); + }); + + it('accepts a config that starts with a byte order mark', async () => { + const file = path.join(root, 'bom.json'); + writeFileSync( + file, + '\uFEFF{ "mcpServers": { "own": { "url": "https://x" } } }' + ); + + const { status } = await writeJsonServerEntry(file, 'mcpServers', 'fc', { + url: MCP_URL, + }); + + const result = read(file); + expect(status).toBe('configured'); + expect(result.startsWith('\uFEFF')).toBe(true); + expect(result).toContain('"own"'); + expect(result).toContain(MCP_URL); + }); + + it('reports reconfigured when the server is already present', async () => { + const file = path.join(root, 'mcp.json'); + writeFileSync( + file, + JSON.stringify({ mcpServers: { fc: { url: 'https://old' } } }) + ); + + const { status } = await writeJsonServerEntry(file, 'mcpServers', 'fc', { + url: MCP_URL, + }); + + expect(status).toBe('reconfigured'); + expect(JSON.parse(read(file)).mcpServers.fc.url).toBe(MCP_URL); + }); + + it('replaces the servers key when it holds a non-object', async () => { + const file = path.join(root, 'mcp.json'); + writeFileSync(file, JSON.stringify({ mcpServers: 'nonsense' })); + + const { status } = await writeJsonServerEntry(file, 'mcpServers', 'fc', { + url: MCP_URL, + }); + + expect(status).toBe('configured'); + expect(JSON.parse(read(file)).mcpServers.fc.url).toBe(MCP_URL); + }); + + it('refuses to overwrite a config it cannot parse', async () => { + const file = path.join(root, 'mcp.json'); + const broken = '{ "mcpServers": { oops\n'; + writeFileSync(file, broken); + + await expect( + writeJsonServerEntry(file, 'mcpServers', 'fc', { url: MCP_URL }) + ).rejects.toThrow('could not parse existing config'); + expect(read(file)).toBe(broken); + }); + }); + + describe('upsertTomlServer', () => { + it('appends after root keys when the server is absent', () => { + const { content, alreadyExists } = upsertTomlServer( + 'model = "gpt-5"\n', + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(false); + expect(content).toBe( + `model = "gpt-5"\n\n[mcp_servers.firecrawl]\nurl = "${MCP_URL}"\n` + ); + }); + + it('does not delete an unrelated table between a server and its sub-table', () => { + const existing = [ + '[mcp_servers.firecrawl]', + 'command = "npx"', + '', + '[mcp_servers.other]', + 'url = "https://example.com/mcp"', + '', + '[mcp_servers.firecrawl.env]', + 'FIRECRAWL_API_KEY = "fc-old"', + '', + ].join('\n'); + + const { content, alreadyExists } = upsertTomlServer( + existing, + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(true); + expect(content).toContain('[mcp_servers.other]'); + expect(content).toContain('https://example.com/mcp'); + expect(content).not.toContain('mcp_servers.firecrawl.env'); + expect(content).not.toContain('fc-old'); + expect(content).toContain(`url = "${MCP_URL}"`); + }); + + it('rewrites a table that the next table follows without a blank line', () => { + // Nothing requires the blank line the other fixtures have, and a config + // written by hand or by another tool often does without it. + const existing = [ + '[mcp_servers.firecrawl]', + 'command = "npx"', + '[mcp_servers.other]', + 'url = "https://example.com/mcp"', + '', + ].join('\n'); + + const { content } = upsertTomlServer(existing, 'firecrawl', { + url: MCP_URL, + }); + + expect(content).toBe( + `[mcp_servers.firecrawl]\nurl = "${MCP_URL}"\n[mcp_servers.other]\nurl = "https://example.com/mcp"\n` + ); + }); + + it('rewrites a stdio entry whose sub-table sits on the next line', () => { + // The documented stdio layout: the env sub-table directly under it. + const existing = [ + '[mcp_servers.firecrawl]', + 'command = "npx"', + '[mcp_servers.firecrawl.env]', + 'FIRECRAWL_API_KEY = "fc-old"', + '[mcp_servers.other]', + 'url = "https://example.com/mcp"', + '', + ].join('\n'); + + const { content } = upsertTomlServer(existing, 'firecrawl', { + url: MCP_URL, + }); + + expect(content).toBe( + `[mcp_servers.firecrawl]\nurl = "${MCP_URL}"\n[mcp_servers.other]\nurl = "https://example.com/mcp"\n` + ); + expect(content).not.toContain('fc-old'); + }); + + it('keeps a comment that follows the table on its own line', () => { + const existing = [ + '[mcp_servers.firecrawl]', + 'command = "npx"', + '# keep me', + '[other]', + 'x = 1', + '', + ].join('\n'); + + const { content } = upsertTomlServer(existing, 'firecrawl', { + url: MCP_URL, + }); + + expect(content).toContain(`url = "${MCP_URL}"\n# keep me`); + }); + + it('replaces a stale stdio entry along with its sub-tables', () => { + const existing = [ + 'model = "gpt-5"', + '', + '[mcp_servers.firecrawl]', + 'command = "npx"', + 'args = ["-y", "firecrawl-mcp"]', + '', + '[mcp_servers.firecrawl.env]', + 'FIRECRAWL_API_KEY = "fc-old"', + '', + '[mcp_servers.other]', + 'url = "https://example.com/mcp"', + '', + ].join('\n'); + + const { content, alreadyExists } = upsertTomlServer( + existing, + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(true); + expect(content).not.toContain('firecrawl-mcp'); + expect(content).not.toContain('fc-old'); + expect(content).not.toContain('mcp_servers.firecrawl.env'); + expect(content).toContain('[mcp_servers.other]'); + expect(content).toContain('model = "gpt-5"'); + expect(content).toContain(`url = "${MCP_URL}"`); + }); + + it('matches an existing table in a CRLF file instead of duplicating it', () => { + const crlf = + 'model = "gpt-5"\r\n\r\n[mcp_servers.firecrawl]\r\nurl = "https://old"\r\n'; + + const { content, alreadyExists } = upsertTomlServer(crlf, 'firecrawl', { + url: MCP_URL, + }); + + expect(alreadyExists).toBe(true); + expect(content.match(/\[mcp_servers\.firecrawl\]/g)).toHaveLength(1); + expect(content).toContain('\r\n'); + expect( + upsertTomlServer(content, 'firecrawl', { url: MCP_URL }).content + ).toBe(content); + }); + + it('keeps comments that introduce the following table', () => { + const existing = [ + '[mcp_servers.firecrawl]', + 'url = "https://old"', + '', + '# notes about the next server', + '[mcp_servers.other]', + 'url = "https://example.com/mcp"', + '', + ].join('\n'); + + const { content } = upsertTomlServer(existing, 'firecrawl', { + url: MCP_URL, + }); + + expect(content).toContain('# notes about the next server'); + expect(content).toContain('[mcp_servers.other]'); + }); + + it('is stable across repeated writes', () => { + const first = upsertTomlServer('', 'firecrawl', { url: MCP_URL }).content; + const second = upsertTomlServer(first, 'firecrawl', { + url: MCP_URL, + }).content; + + expect(second).toBe(first); + }); + + it('ignores table syntax written inside a multi-line string', () => { + const existing = [ + 'instructions = """', + '[mcp_servers.firecrawl]', + 'url = "https://not-a-table"', + '"""', + '', + ].join('\n'); + + const { content, alreadyExists } = upsertTomlServer( + existing, + 'firecrawl', + { url: MCP_URL } + ); + + // The string keeps its contents and the real table is appended after it. + expect(alreadyExists).toBe(false); + expect(content).toContain('url = "https://not-a-table"'); + expect(content).toMatch( + new RegExp(`\\[mcp_servers\\.firecrawl\\]\\nurl = ".*"\\n$`) + ); + }); + + it('does not end a basic string on an escaped fence', () => { + const existing = [ + 'instructions = """', + String.raw`he said \""" loudly`, + '[mcp_servers.firecrawl]', + 'url = "https://not-a-table"', + '"""', + '', + ].join('\n'); + + const { content, alreadyExists } = upsertTomlServer( + existing, + 'firecrawl', + { url: MCP_URL } + ); + + // Everything above stays string content, so nothing in it is replaced. + expect(alreadyExists).toBe(false); + expect(content).toContain(String.raw`he said \""" loudly`); + expect(content).toContain('url = "https://not-a-table"'); + expect(content).toMatch( + new RegExp(`\\[mcp_servers\\.firecrawl\\]\\nurl = "${MCP_URL}"\\n$`) + ); + }); + + it('closes a basic string when the fence follows an escaped backslash', () => { + const existing = [ + 'instructions = """', + String.raw`trailing slash \\"""`, + '[mcp_servers.firecrawl]', + 'url = "https://old"', + '', + ].join('\n'); + + const { content, alreadyExists } = upsertTomlServer( + existing, + 'firecrawl', + { url: MCP_URL } + ); + + // The run of backslashes is even, so the fence really does terminate and + // the table below it is a real one to replace. + expect(alreadyExists).toBe(true); + expect(content).toContain(`url = "${MCP_URL}"`); + expect(content).not.toContain('https://old'); + }); + + it('refuses a config whose multi-line string is never closed', () => { + expect(() => + upsertTomlServer('instructions = """\nstill open\n', 'firecrawl', { + url: MCP_URL, + }) + ).toThrow(/unterminated string/i); + }); + + it.each([ + ['quoted_server', '[mcp_servers."firecrawl"]\nurl = "https://old"\n'], + [ + 'single_quoted_server', + '[mcp_servers.\'firecrawl\']\nurl = "https://old"\n', + ], + ['quoted_parent', '["mcp_servers".firecrawl]\nurl = "https://old"\n'], + [ + 'whitespace_dotted', + '[ mcp_servers . firecrawl ]\nurl = "https://old"\n', + ], + ['utf8_bom', '\uFEFF[mcp_servers.firecrawl]\nurl = "https://old"\n'], + ] as const)( + 'replaces a %s table instead of appending a duplicate', + (_name, existing) => { + const { content, alreadyExists } = upsertTomlServer( + existing, + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(true); + expect(tomlHasServer(content, 'firecrawl')).toBe(true); + expect(content.match(/mcp_servers/g)).toHaveLength(1); + expect(content).toContain(`url = "${MCP_URL}"`); + expect(content).not.toContain('https://old'); + expect(() => + parseTOML(content.startsWith('\uFEFF') ? content.slice(1) : content) + ).not.toThrow(); + if (existing.startsWith('\uFEFF')) { + expect(content.startsWith('\uFEFF')).toBe(true); + } + } + ); + + it('refuses to append when firecrawl already exists as an inline table', () => { + expect(() => + upsertTomlServer( + 'mcp_servers = { firecrawl = { url = "https://old" } }\n', + 'firecrawl', + { url: MCP_URL } + ) + ).toThrow(/inline/); + }); + }); + + describe('upsertYamlServer', () => { + it('keeps the comments and formatting around an added server', () => { + const existing = [ + '# Hermes configuration', + 'model: anthropic/claude-opus-4.6 # my preferred model', + '', + 'mcp_servers:', + ' github:', + ' command: npx', + '', + ].join('\n'); + + const { content, alreadyExists } = upsertYamlServer( + existing, + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(false); + expect(content).toContain('# Hermes configuration'); + expect(content).toContain('# my preferred model'); + expect(content).toContain('command: npx'); + expect(content).toContain(`url: ${MCP_URL}`); + }); + + it('builds the server map when the file is empty', () => { + const { content, alreadyExists } = upsertYamlServer( + '', + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(false); + expect(parseYaml(content)).toEqual({ + mcp_servers: { firecrawl: { url: MCP_URL } }, + }); + }); + + it('reports an existing entry as already present and replaces it', () => { + const existing = 'mcp_servers:\n firecrawl:\n url: https://old\n'; + + const { content, alreadyExists } = upsertYamlServer( + existing, + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(true); + expect(content).toContain(MCP_URL); + expect(content).not.toContain('https://old'); + }); + + it('fills in a server section that exists but is empty', () => { + const { content, alreadyExists } = upsertYamlServer( + 'model: opus\nmcp_servers:\n', + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(false); + expect(parseYaml(content)).toEqual({ + model: 'opus', + mcp_servers: { firecrawl: { url: MCP_URL } }, + }); + }); + + it('keeps a comment that sat on the empty section', () => { + const { content } = upsertYamlServer( + 'model: opus\nmcp_servers: # servers live here\n', + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ); + + expect(content).toContain('# servers live here'); + expect(parseYaml(content)).toEqual({ + model: 'opus', + mcp_servers: { firecrawl: { url: MCP_URL } }, + }); + }); + + it('keeps a byte order mark and CRLF line endings', () => { + const existing = + '\uFEFFmodel: opus\r\nterminal:\r\n backend: docker\r\n'; + + const { content } = upsertYamlServer( + existing, + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ); + + expect(content.startsWith('\uFEFF')).toBe(true); + expect(content).toContain('\r\n'); + expect(/[^\r]\n/.test(content)).toBe(false); + expect(parseYaml(content.slice(1))).toMatchObject({ + model: 'opus', + mcp_servers: { firecrawl: { url: MCP_URL } }, + }); + }); + + it('refuses a config that does not parse', () => { + expect(() => + upsertYamlServer( + 'model: "unterminated\nother: 1\n', + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ) + ).toThrow(/quote/i); + }); + + it('refuses a server section that is a scalar or a list', () => { + expect(() => + upsertYamlServer('mcp_servers: foo\n', 'mcp_servers', 'firecrawl', { + url: MCP_URL, + }) + ).toThrow(/mapping/i); + expect(() => + upsertYamlServer('mcp_servers:\n - a\n', 'mcp_servers', 'firecrawl', { + url: MCP_URL, + }) + ).toThrow(/mapping/i); + }); + }); + + describe('appendRuleSection', () => { + it('keeps existing content and replaces only the fenced section', async () => { + const file = path.join(root, 'AGENTS.md'); + writeFileSync(file, '# My project\n\nRun tests with pnpm test.\n'); + + expect(await appendRuleSection(file, 'first\n')).toBe('installed'); + expect(await appendRuleSection(file, 'second\n')).toBe('updated'); + + const result = read(file); + expect(result).toContain('# My project'); + expect(result).toContain('Run tests with pnpm test.'); + expect(result).toContain('second'); + expect(result).not.toContain('first'); + expect(result.match(//g)).toHaveLength(2); + }); + + it('does not eat user content sitting under a stray marker', async () => { + // A half-written run or a hand edit can leave one marker behind. Pairing + // it with our opening marker would delete everything between them. + const file = path.join(root, 'AGENTS.md'); + writeFileSync(file, '\nIMPORTANT USER CONTENT\n'); + + // Nothing to replace on the way in: one marker is not a section. + expect(await appendRuleSection(file, 'first\n')).toBe('installed'); + expect(await appendRuleSection(file, 'second\n')).toBe('updated'); + + const result = read(file); + expect(result).toContain('IMPORTANT USER CONTENT'); + expect(result).toContain('second'); + expect(result).not.toContain('first'); + }); + }); + + describe('appendRuleSection line endings', () => { + it('replaces its section after the file is converted to CRLF', async () => { + const file = path.join(root, 'AGENTS.md'); + + expect(await appendRuleSection(file, 'first\n')).toBe('installed'); + writeFileSync(file, read(file).replace(/\n/g, '\r\n')); + + expect(await appendRuleSection(file, 'second\n')).toBe('updated'); + const result = read(file); + expect(result.match(//g)).toHaveLength(2); + expect(result).not.toContain('first'); + }); + }); + + it('keeps the line endings of a CRLF rule file', async () => { + const file = path.join(root, 'AGENTS.md'); + writeFileSync(file, '# Title\r\n\r\nBody line.\r\n'); + + await appendRuleSection(file, 'RULE ONE\nRULE TWO\n'); + + const written = read(file); + expect(written).toContain('\r\n'); + expect(/[^\r]\n/.test(written)).toBe(false); + expect(written).toContain('Body line.'); + }); + + describe('setupMcpClient', () => { + it('writes the keyless URL with no credentials', async () => { + const result = await setupMcpClient('cursor', { + rules: false, + ctx, + }); + + expect(result.mcpStatus).toBe('configured'); + expect(result.ruleStatus).toBe('skipped'); + expect( + JSON.parse(read(ctx.home, '.cursor', 'mcp.json')).mcpServers.firecrawl + ).toEqual({ url: MCP_URL }); + }); + + it('references the env var instead of writing a credential', async () => { + const result = await setupMcpClient('claude', { + rules: false, + ctx: { ...ctx, auth: 'env' }, + }); + + expect(result.mcpStatus).toBe('configured'); + expect(result.auth).toBe('env'); + expect( + JSON.parse(read(ctx.home, '.claude.json')).mcpServers.firecrawl + ).toEqual({ + type: 'http', + url: MCP_URL, + headers: { Authorization: 'Bearer ${FIRECRAWL_API_KEY}' }, + }); + }); + + it('honours CLAUDE_CONFIG_DIR', async () => { + const configDir = path.join(root, 'claude-config'); + const result = await setupMcpClient('claude', { + rules: true, + ctx: { ...ctx, env: { CLAUDE_CONFIG_DIR: configDir } }, + }); + + expect(result.mcpDetail).toBe(path.join(configDir, '.claude.json')); + expect(result.ruleDetail).toBe( + path.join(configDir, 'rules', 'firecrawl.md') + ); + }); + + it('uses the environment-reference syntax each agent expands', async () => { + const written: Record = {}; + for (const id of ['cursor', 'vscode', 'opencode'] as const) { + const result = await setupMcpClient(id, { + rules: false, + ctx: { ...ctx, auth: 'env' }, + }); + written[id] = JSON.parse(read(result.mcpDetail)); + } + + expect((written.cursor as any).mcpServers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', + }); + expect((written.vscode as any).servers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', + }); + expect((written.opencode as any).mcp.firecrawl.headers).toEqual({ + Authorization: 'Bearer {env:FIRECRAWL_API_KEY}', + }); + }); + + it('authenticates Codex through its native bearer token variable', async () => { + await setupMcpClient('codex', { + rules: false, + ctx: { ...ctx, auth: 'env' }, + }); + + const config = read(ctx.home, '.codex', 'config.toml'); + expect(config).toContain('bearer_token_env_var = "FIRECRAWL_API_KEY"'); + }); + + it('still configures MCP when the rule write fails', async () => { + // A file where the instructions directory needs to be blocks the write. + const instructionsPath = path.join(ctx.home, '.copilot', 'instructions'); + mkdirSync(path.dirname(instructionsPath), { recursive: true }); + writeFileSync(instructionsPath, 'not a directory'); + + const result = await setupMcpClient('vscode', { + rules: true, + ctx, + }); + + expect(result.mcpStatus).toBe('configured'); + expect(result.ruleStatus).toBe('failed'); + }); + + it('reports rules as unsupported when the agent has no rule file', async () => { + const result = await setupMcpClient('hermes', { rules: true, ctx }); + + expect(result.mcpStatus).toBe('configured'); + expect(result.ruleStatus).toBe('unsupported'); + expect(result.ruleDetail).toBe(''); + }); + + it('does not write a project-scoped Cursor rule as a global install', async () => { + const result = await setupMcpClient('cursor', { rules: true, ctx }); + + expect(result.mcpStatus).toBe('configured'); + expect(result.ruleStatus).toBe('unsupported'); + expect(result.ruleDetail).toContain('Customize → Rules'); + expect( + existsSync(path.join(ctx.home, '.cursor', 'rules', 'firecrawl.mdc')) + ).toBe(false); + }); + + it('writes VS Code instructions under ~/.copilot/instructions', async () => { + const result = await setupMcpClient('vscode', { rules: true, ctx }); + const rulePath = path.join( + ctx.home, + '.copilot', + 'instructions', + 'firecrawl.instructions.md' + ); + + expect(result.mcpStatus).toBe('configured'); + expect(result.ruleStatus).toBe('installed'); + expect(result.ruleDetail).toBe(rulePath); + expect(read(rulePath)).toContain("applyTo: '**'"); + }); + + it('writes no rule for an agent whose MCP entry failed', async () => { + const file = path.join( + ctx.home, + 'Library', + 'Application Support', + 'Code', + 'User', + 'mcp.json' + ); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, '{ oops'); + + const result = await setupMcpClient('vscode', { rules: true, ctx }); + + // A rule without a server points the agent at tools it does not have. + expect(result.mcpStatus).toBe('failed'); + expect(result.ruleStatus).toBe('skipped'); + expect( + existsSync( + path.join( + ctx.home, + '.copilot', + 'instructions', + 'firecrawl.instructions.md' + ) + ) + ).toBe(false); + }); + + it('honours CODEX_HOME for config, rules, and detection', async () => { + const home = path.join(root, 'codex-home'); + const isolated = { ...ctx, env: { CODEX_HOME: home } }; + + const result = await setupMcpClient('codex', { + rules: true, + ctx: isolated, + }); + + expect(result.mcpDetail).toBe(path.join(home, 'config.toml')); + expect(result.ruleDetail).toBe(path.join(home, 'AGENTS.md')); + expect(existsSync(path.join(ctx.home, '.codex'))).toBe(false); + expect(await detectMcpClients(isolated)).toEqual(['codex']); + expect(await detectMcpClients(ctx)).toEqual([]); + }); + + it('honours HERMES_HOME for config and detection', async () => { + const home = path.join(root, 'hermes-home'); + const isolated = { ...ctx, env: { HERMES_HOME: home } }; + + const result = await setupMcpClient('hermes', { + rules: false, + ctx: isolated, + }); + + expect(result.mcpDetail).toBe(path.join(home, 'config.yaml')); + expect(existsSync(path.join(ctx.home, '.hermes'))).toBe(false); + expect(await detectMcpClients(isolated)).toEqual(['hermes']); + expect(await detectMcpClients(ctx)).toEqual([]); + }); + + it('configures every client in each auth mode without writing a literal key', async () => { + const modes: McpAuthMode[] = ['keyless', 'env', 'oauth']; + for (const auth of modes) { + for (const id of ALL_MCP_CLIENT_IDS) { + const isolated: McpContext = { + ...ctx, + home: path.join(ctx.home, auth, id), + auth, + }; + mkdirSync(isolated.home, { recursive: true }); + const result = await setupMcpClient(id, { + rules: false, + ctx: isolated, + }); + expect(result.mcpStatus, `${id} ${auth}`).toBe('configured'); + expect(read(result.mcpDetail)).not.toContain('fc-'); + } + } + }); + + it('reports failure without touching an unparseable config', async () => { + const file = path.join(ctx.home, '.cursor', 'mcp.json'); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, '{ oops'); + + const result = await setupMcpClient('cursor', { + rules: false, + ctx, + }); + + expect(result.mcpStatus).toBe('failed'); + expect(result.mcpDetail).toContain('could not parse'); + expect(read(file)).toBe('{ oops'); + }); + }); + + describe('detectMcpClients', () => { + it('reports only agents present on disk', async () => { + mkdirSync(path.join(ctx.home, '.cursor'), { recursive: true }); + mkdirSync(path.join(ctx.home, '.codex'), { recursive: true }); + + expect(await detectMcpClients(ctx)).toEqual(['cursor', 'codex']); + }); + + it('detects Claude Code from ~/.claude.json without ~/.claude', async () => { + writeFileSync(path.join(ctx.home, '.claude.json'), '{}'); + + expect(await detectMcpClients(ctx)).toEqual(['claude']); + }); + + it('detects VS Code from ~/.vscode without its User directory', async () => { + mkdirSync(path.join(ctx.home, '.vscode'), { recursive: true }); + + expect(await detectMcpClients(ctx)).toEqual(['vscode']); + }); + }); + + describe('detectMcpLaunchers', () => { + it('does not treat a directory on PATH as OpenClaw', () => { + const bin = path.join(root, 'bin'); + mkdirSync(path.join(bin, 'openclaw'), { recursive: true }); + ctx.env = { ...ctx.env, PATH: bin }; + + expect(detectMcpLaunchers(ctx)).toEqual([]); + }); + + it('does not treat a non-executable PATH file as OpenClaw', () => { + if (process.platform === 'win32') return; + + const bin = path.join(root, 'bin'); + mkdirSync(bin, { recursive: true }); + writeFileSync(path.join(bin, 'openclaw'), ''); + ctx.env = { ...ctx.env, PATH: bin }; + + expect(detectMcpLaunchers(ctx)).toEqual([]); + }); + + it('detects OpenClaw from an executable on PATH', () => { + const bin = path.join(root, 'bin'); + mkdirSync(bin, { recursive: true }); + const binary = path.join(bin, 'openclaw'); + writeFileSync(binary, ''); + chmodSync(binary, 0o755); + ctx.env = { ...ctx.env, PATH: bin }; + + expect(detectMcpLaunchers(ctx)).toEqual(['openclaw']); + }); + }); + + describe('resolveMcpClientId', () => { + it('accepts the aliases used by launch targets', () => { + expect(resolveMcpClientId('claude-code')).toBe('claude'); + expect(resolveMcpClientId('Codex-App')).toBe('codex'); + expect(resolveMcpClientId('vs-code')).toBe('vscode'); + expect(resolveMcpClientId('nope')).toBeUndefined(); + }); + + it('rejects names inherited from the alias table prototype', () => { + expect(resolveMcpClientId('__proto__')).toBeUndefined(); + expect(resolveMcpClientId('constructor')).toBeUndefined(); + }); + }); +}); diff --git a/src/commands/init.ts b/src/commands/init.ts index 144bcfd848..1822f279e7 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -287,7 +287,8 @@ function parseSkillCount(output: string): number | null { */ function printNextSteps( skillCount: number | null, - defaultsHandled = false + defaultsHandled = false, + mcpInstalled = false ): void { const arrow = `${dim}→${reset}`; const summary = @@ -311,9 +312,13 @@ function printNextSteps( ` ${arrow} ${bold}Interact${reset} "Go to amazon.com, search keyboards, filter by Prime" ${dim}firecrawl interact "search keyboards, filter by Prime"${reset}` ); console.log(''); - console.log( - ` ${arrow} ${dim}Add MCP: ${reset} ${bold}firecrawl setup mcp${reset}` - ); + // Only for someone who does not have it yet: the install already reported + // its own ✓ a few lines up. + if (!mcpInstalled) { + console.log( + ` ${arrow} ${dim}Add MCP: ${reset} ${bold}firecrawl setup mcp${reset}` + ); + } if (!defaultsHandled) { console.log( ` ${arrow} ${dim}Default web:${reset} ${bold}firecrawl setup defaults${reset}` @@ -508,7 +513,15 @@ export async function stepAuth(options: InitOptions): Promise { } } -async function stepIntegrations(options: InitOptions): Promise { +interface IntegrationsResult { + skillCount: number | null; + /** Drives the next-steps block: a successful install drops "Add MCP". */ + mcpInstalled: boolean; +} + +async function stepIntegrations( + options: InitOptions +): Promise { const { checkbox, confirm } = await import('@inquirer/prompts'); const wantIntegrations = await confirm({ @@ -516,7 +529,7 @@ async function stepIntegrations(options: InitOptions): Promise { default: true, }); - if (!wantIntegrations) return null; + if (!wantIntegrations) return { skillCount: null, mcpInstalled: false }; const integrations = await checkbox({ message: 'Which integrations?', @@ -532,7 +545,10 @@ async function stepIntegrations(options: InitOptions): Promise { checked: true, }, { - name: 'MCP — install firecrawl MCP server for editors (Cursor, Claude Code, VS Code)', + // Named like the skills entry: setup writes to whatever it detects, so + // listing a few agents here would undersell it and listing all seven + // would not fit. + name: 'MCP — install the Firecrawl MCP server for detected coding agents', value: 'mcp', }, { @@ -544,7 +560,7 @@ async function stepIntegrations(options: InitOptions): Promise { if (integrations.length === 0) { console.log(` ${dim}No integrations selected.${reset}\n`); - return null; + return { skillCount: null, mcpInstalled: false }; } // If skills/workflows are being installed, let the user route them to a @@ -560,6 +576,7 @@ async function stepIntegrations(options: InitOptions): Promise { : null; let totalSkills: number | null = null; + let mcpInstalled = false; for (const integration of integrations) { switch (integration) { case 'skills': { @@ -605,9 +622,15 @@ async function stepIntegrations(options: InitOptions): Promise { apiKey && process.env.FIRECRAWL_API_KEY === apiKey ); try { - await installMcp({ + // Setup reports each agent itself, and a run where only some of them + // landed resolves rather than throwing, so claim success only when it + // says every agent was configured. + mcpInstalled = await installMcp({ global: options.global, - agent: options.agent ?? (environmentBacked ? 'all' : undefined), + // No agent means "every agent detected here". Naming "all" instead + // would write config for agents the user does not have, and setup + // resolves an exported key on its own either way. + agent: options.agent, yes: true, quiet: true, // Stored credentials must never be persisted into MCP client config. @@ -615,7 +638,11 @@ async function stepIntegrations(options: InitOptions): Promise { // credential continues through the authenticated setup path. keyless: !environmentBacked, }); - console.log(` ${green}✓${reset} MCP server installed`); + console.log( + mcpInstalled + ? ` ${green}✓${reset} MCP server installed` + : ` ${dim}Run "firecrawl setup mcp" later to finish the rest.${reset}` + ); } catch (error) { const message = error instanceof Error @@ -640,7 +667,7 @@ async function stepIntegrations(options: InitOptions): Promise { } } } - return totalSkills; + return { skillCount: totalSkills, mcpInstalled }; } /** @@ -944,8 +971,9 @@ export async function handleInitCommand( // Step 3: Integrations (skills, MCP, env) let skillCount: number | null = null; + let mcpInstalled = false; if (!options.skipSkills) { - skillCount = await stepIntegrations(options); + ({ skillCount, mcpInstalled } = await stepIntegrations(options)); } // Step 4: Template @@ -954,7 +982,7 @@ export async function handleInitCommand( // Step 5: Default web provider await stepDefaults(); - printNextSteps(skillCount, true); + printNextSteps(skillCount, true, mcpInstalled); } async function runNonInteractive(options: InitOptions): Promise { diff --git a/src/commands/launch.ts b/src/commands/launch.ts index e0b1bcc00f..8a0038bf32 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -235,6 +235,15 @@ export async function handleLaunchCommand( throw new Error('--keyless cannot be combined with --skip-mcp.'); } + const installOnly = Boolean( + options.config || options.install || options.setup + ); + if (installOnly && options.skipMcp && options.skipSkills) { + throw new Error( + 'Install mode (--install, --setup, --config) cannot be combined with both --skip-mcp and --skip-skills.' + ); + } + if (!targetName && extraArgs.length > 0) { throw new Error( 'Extra launch arguments require an explicit launch target.' @@ -252,9 +261,6 @@ export async function handleLaunchCommand( const targetSupportsSkills = Boolean(target.skillsAgent); let installMcpForTarget = targetSupportsMcp && !options.skipMcp; let installSkillsForTarget = targetSupportsSkills && !options.skipSkills; - const installOnly = Boolean( - options.config || options.install || options.setup - ); const apiKey = options.keyless ? undefined : getApiKey(); const runtimeEnv = !installOnly && apiKey && process.env.FIRECRAWL_API_KEY !== apiKey @@ -301,7 +307,11 @@ export async function handleLaunchCommand( } if (installOnly) { - console.log(`${target.displayName} is configured with Firecrawl MCP.`); + if (installMcpForTarget) { + console.log(`${target.displayName} is configured with Firecrawl MCP.`); + } else if (installSkillsForTarget) { + console.log(`${target.displayName} is configured with Firecrawl skills.`); + } return; } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 1ed3a4a0fd..436de78cd0 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -3,18 +3,11 @@ * Installs firecrawl skill files and MCP server into AI coding agents */ -import { execFileSync, execSync } from 'child_process'; -import { - chmodSync, - existsSync, - mkdirSync, - readFileSync, - writeFileSync, -} from 'fs'; +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; import os from 'os'; import path from 'path'; import readline from 'readline'; -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { getApiKey } from '../utils/config'; import { buildSkillsInstallArgs, @@ -22,27 +15,55 @@ import { SKILL_REPOS, WORKFLOW_SKILL_REPOS, } from './skills-install'; -import { hasNpx, installSkillsNative } from './skills-native'; +import { + hasNpx, + installSkillsNative, + isSkillsAgentName, +} from './skills-native'; import { configureWebDefaults, WEB_AGENTS, type WebAgent, } from '../utils/web-defaults'; +import { + ALL_MCP_CLIENT_IDS, + FIRECRAWL_MCP_URL, + ALL_MCP_LAUNCHER_IDS, + ALL_MCP_TARGET_IDS, + detectMcpClients, + detectMcpLaunchers, + FIRECRAWL_MCP_OAUTH_URL, + isMcpLauncherId, + MCP_CLIENTS, + MCP_LAUNCHER_OAUTH, + MCP_LAUNCHER_RULES, + mcpTargetName, + resolveMcpClientId, + type McpAuthMode, + type McpContext, + type McpLauncherId, + type McpTargetId, +} from '../utils/mcp-clients'; +import { + setupMcpClient, + writeConfiguredRule, + type McpClientResult, +} from '../utils/mcp-install'; +import { runClientCommand } from '../utils/run-client-command'; export type SetupSubcommand = 'skills' | 'workflows' | 'mcp' | 'defaults'; type SetupIntegration = SetupSubcommand; type ResolvedMcpAgent = - | { kind: 'add-mcp'; agent?: string; all?: boolean } - | { kind: 'hermes' } + | { kind: 'clients'; ids?: McpTargetId[] } + | { kind: 'launchers' } + | { kind: 'skills-only'; agent: string } | { kind: 'openclaw' } | { kind: 'all-launchers' }; export interface SetupOptions { global?: boolean; - /** Explicitly install MCP into project scope. */ - project?: boolean; agent?: string; undo?: boolean; /** Skip the interactive harness picker and apply to all agents. */ @@ -53,20 +74,20 @@ export interface SetupOptions { quiet?: boolean; /** Configure the anonymous hosted MCP path even when a stored key exists. */ keyless?: boolean; + /** Point agents at the sign-in endpoint instead of sending a credential. */ + oauth?: boolean; + /** Agents chosen by flag (`--claude`, `--cursor`, ...); skips the picker. */ + clients?: McpTargetId[]; + /** Force the Firecrawl web rules on or off instead of prompting. */ + rules?: boolean; } const green = '\x1b[32m'; +const red = '\x1b[31m'; +const bold = '\x1b[1m'; const dim = '\x1b[2m'; const reset = '\x1b[0m'; -const ADD_MCP_PACKAGE = 'add-mcp@1.14.0'; const ENV_API_KEY = 'FIRECRAWL_API_KEY'; -const ADD_MCP_LAUNCH_AGENTS = [ - 'claude-code', - 'vscode', - 'codex', - 'opencode', - 'cursor', -] as const; const SKILL_REPO_LABELS: Record = { 'firecrawl/cli': 'Core CLI skills', @@ -78,109 +99,8 @@ function skillRepoLabel(repo: string): string { return SKILL_REPO_LABELS[repo] ?? repo; } -const CMD_META_CHARS = /([()%!^"<>&|])/g; - -function rejectCommandControlCharacters(value: string, label: string): void { - if (/[\0\r\n]/.test(value)) { - throw new Error(`${label} contains an unsupported control character.`); - } -} - -/** Quote one argv value for cmd.exe using the same two-layer escaping model as - * established Windows spawn libraries: first the C runtime, then cmd.exe. */ -function escapeCmdArg(arg: string): string { - rejectCommandControlCharacters(arg, 'Command argument'); - const quoted = `"${arg - .replace(/(\\*)"/g, '$1$1\\"') - .replace(/(\\*)$/, '$1$1')}"`; - return quoted.replace(CMD_META_CHARS, '^$1'); -} - -function windowsPathExtensions(env: NodeJS.ProcessEnv): string[] { - const configured = env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD'; - return configured - .split(';') - .map((extension) => extension.trim()) - .filter(Boolean); -} - -/** Resolve the actual Windows launcher instead of assuming every tool is a - * `.cmd` shim. Native `.exe` clients must bypass cmd.exe entirely. */ -function resolveWindowsCommand( - command: string, - env: NodeJS.ProcessEnv -): string { - rejectCommandControlCharacters(command, 'Command'); - const hasPath = /[\\/]/.test(command); - const hasExtension = path.extname(command) !== ''; - const candidates = hasExtension - ? [command] - : windowsPathExtensions(env).map((extension) => `${command}${extension}`); - const pathEntries = hasPath - ? [''] - : (env.PATH ?? env.Path ?? env.path ?? '') - .split(path.delimiter) - .map((entry) => entry.replace(/^"|"$/g, '')) - .filter(Boolean); - - for (const directory of pathEntries) { - for (const candidate of candidates) { - const resolved = directory ? path.join(directory, candidate) : candidate; - if (existsSync(resolved)) return resolved; - } - } - - // Let CreateProcess perform its normal resolution for native executables. - // Crucially, do not silently rewrite an unknown command to `.cmd`. - return command; -} - -/** - * Cross-platform, injection-safe replacement for `execFileSync`. - * - * On win32, external tools ship as `.cmd`/`.bat` shims (npx.cmd, npm.cmd, - * codex.cmd, openclaw.cmd). Node's `execFile`/`execFileSync` calls CreateProcess - * directly and CANNOT launch a `.cmd`/`.bat` file — it throws ENOENT/EINVAL. The - * only reliable way is to route through the shell (cmd.exe). To keep the argv - * safety this file relies on (secrets must never be shell-interpreted), we - * escape every argument for cmd.exe ourselves instead of letting the shell - * re-split a joined string. - * - * On every other platform we spawn the binary directly with no shell, exactly as - * `execFileSync` did before. - */ -function runClientCommand( - command: string, - args: string[], - options: Parameters[2] -): void { - rejectCommandControlCharacters(command, 'Command'); - for (const arg of args) - rejectCommandControlCharacters(arg, 'Command argument'); - - if (process.platform !== 'win32') { - execFileSync(command, args, options); - return; - } - - const env = options?.env ?? process.env; - const resolved = resolveWindowsCommand(command, env); - if (!/\.(?:cmd|bat)$/i.test(resolved)) { - execFileSync(resolved, args, options); - return; - } - - const line = [escapeCmdArg(resolved), ...args.map(escapeCmdArg)].join(' '); - const comspec = env.ComSpec ?? env.COMSPEC ?? 'cmd.exe'; - const windowsOptions = { - ...options, - windowsVerbatimArguments: true, - } as Parameters[2]; - execFileSync(comspec, ['/d', '/s', '/c', `"${line}"`], windowsOptions); -} - -function firecrawlHostedMcpUrl(): string { - return 'https://mcp.firecrawl.dev/v2/mcp'; +function firecrawlHostedMcpUrl(oauth = false): string { + return oauth ? FIRECRAWL_MCP_OAUTH_URL : FIRECRAWL_MCP_URL; } function isEnvironmentBackedApiKey( @@ -236,37 +156,30 @@ function firecrawlMcpHeaders( } function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { - if (!agent) return { kind: 'add-mcp' }; + if (!agent) return { kind: 'clients' }; const normalized = agent.trim().toLowerCase(); switch (normalized) { case '*': case 'all': + return { kind: 'all-launchers' }; case 'launchers': case 'launcher': - return { kind: 'all-launchers' }; - case 'claude': - case 'claude-code': - return { kind: 'add-mcp', agent: 'claude-code' }; - case 'code': - case 'vscode': - case 'vs-code': - return { kind: 'add-mcp', agent: 'vscode' }; - case 'codex': - case 'codex-app': - case 'codex-desktop': - case 'codex-gui': - return { kind: 'add-mcp', agent: 'codex' }; - case 'opencode': - case 'open-code': - return { kind: 'add-mcp', agent: 'opencode' }; - case 'hermes': - case 'hermes-agent': - return { kind: 'hermes' }; + return { kind: 'launchers' }; case 'openclaw': return { kind: 'openclaw' }; - default: - return { kind: 'add-mcp', agent }; + default: { + const id = resolveMcpClientId(normalized); + if (id) return { kind: 'clients', ids: [id] }; + // A name we install skills for but write no MCP config for is not an + // error; the caller may have already installed skills for it. + if (isSkillsAgentName(normalized)) { + return { kind: 'skills-only', agent }; + } + throw new Error( + `Unknown agent "${agent}" for setup mcp. Use one of: ${ALL_MCP_TARGET_IDS.join(', ')}, all.` + ); + } } } @@ -334,7 +247,7 @@ async function handleSetupBundle(options: SetupOptions): Promise { const bundleOptions = { ...options, - global: options.project ? undefined : (options.global ?? true), + global: options.global ?? true, }; for (const integration of integrations) { await handleSetupCommand(integration, bundleOptions); @@ -537,217 +450,481 @@ export async function installMcp( // client it starts. This lets MCP config keep an indirect env reference // without mutating the parent shell or exposing the key to setup commands. runtimeEnv: NodeJS.ProcessEnv = process.env -): Promise { - if (options.global && options.project) { - throw new Error('Choose either --global or --project, not both.'); + // True only when every agent this run targeted was configured. Quiet callers + // report their own outcome and are not told about a partial run by an + // exception, so they have to be told by the return value. +): Promise { + // Checked before anything else reports or returns, so no branch can accept a + // combination the writers reject. + if (options.oauth && options.keyless) { + throw new Error( + 'Choose either --oauth or --keyless. Signing in and running anonymously are different endpoints.' + ); } const apiKey = options.keyless ? undefined : getApiKey(); const resolvedAgent = resolveMcpAgent(options.agent); - if (resolvedAgent.kind === 'all-launchers' && options.project && apiKey) { - throw new Error( - 'Authenticated --agent all setup does not support --project because Codex requires a global environment-backed MCP configuration. Choose one --agent for project setup, use --agent all --global, or run keyless setup.' - ); - } - if (!options.agent && isEnvironmentBackedApiKey(apiKey, runtimeEnv)) { - throw new Error( - "Environment-backed MCP setup requires --agent so Firecrawl can use that client's native variable syntax. Choose a supported client or use --agent all; the API key will not be written literally." + // Same rule as installMcpClients: a stored key cannot go into agent config, + // so --agent hermes/openclaw fall back to keyless just like --hermes/--openclaw. + const keyless = !isEnvironmentBackedApiKey(apiKey, runtimeEnv); + + if (resolvedAgent.kind === 'skills-only') { + // Skills for this agent have already installed by this point; ending the + // run here would fail a command that mostly succeeded. + console.log( + `Firecrawl does not write MCP config for ${resolvedAgent.agent}. Point it at ${FIRECRAWL_MCP_URL} to connect it yourself.` ); + return false; } - if (resolvedAgent.kind === 'hermes') { - await installHermesMcp(runtimeEnv, options.keyless); - return; - } - assertSubprocessSafeCredential(apiKey, runtimeEnv); + if (resolvedAgent.kind === 'openclaw') { - await installOpenClawMcp(runtimeEnv, options.keyless); - return; + // Routed through the same reporter as every other target so the keyless + // fallback is stated rather than implied by a bare installer log line. + return installMcpClients(options, runtimeEnv, [resolvedAgent.kind]); + } + if (resolvedAgent.kind === 'launchers') { + return installMcpClients(options, runtimeEnv, [...ALL_MCP_LAUNCHER_IDS]); } if (resolvedAgent.kind === 'all-launchers') { - await installAllMcpLaunchers(options, runtimeEnv); - return; + return installMcpClients(options, runtimeEnv, undefined, { + includeAllLaunchers: true, + }); } - await installAddMcp(options, resolvedAgent, runtimeEnv); + return installMcpClients(options, runtimeEnv, resolvedAgent.ids); } -async function installAllMcpLaunchers( - options: SetupOptions, - runtimeEnv: NodeJS.ProcessEnv -): Promise { - for (const agent of ADD_MCP_LAUNCH_AGENTS) { - await installAddMcp( - { ...options, yes: true }, - { kind: 'add-mcp', agent }, - runtimeEnv - ); +/** Shorten a path for display: relative inside the project, `~` under home. */ +function displayPath(target: string, ctx: McpContext): string { + const relative = path.relative(ctx.cwd, target); + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) { + return relative; } - await installHermesMcp(runtimeEnv, options.keyless); - await installOpenClawMcp(runtimeEnv, options.keyless); + if (target === ctx.home) return '~'; + return target.startsWith(ctx.home + path.sep) + ? path.join('~', path.relative(ctx.home, target)) + : target; } -async function installAddMcp( - options: SetupOptions, - resolvedAgent: Extract, - runtimeEnv: NodeJS.ProcessEnv -): Promise { - const mcpUrl = firecrawlHostedMcpUrl(); - const apiKey = options.keyless ? undefined : getApiKey(); - // Codex has no Authorization template in environmentHeaderForAgent. Its - // native bearer-token option is the verified env indirection, so this must - // remain before the generic firecrawlMcpHeaders path. - if ( - resolvedAgent.agent === 'codex' && - !options.project && - apiKey && - isEnvironmentBackedApiKey(apiKey, runtimeEnv) - ) { - installCodexMcpFromEnvironment(options, mcpUrl); - return; +async function pickMcpClients( + detected: readonly McpTargetId[] +): Promise { + const { checkbox } = await import('@inquirer/prompts'); + return checkbox({ + message: 'Which agents do you want to set up?', + loop: false, + pageSize: detected.length, + choices: detected.map((id) => ({ + name: mcpTargetName(id), + value: id, + checked: true, + })), + }); +} + +/** + * The path under this user's home when `value` names one, matching the only + * two forms a shell expands against the current user. `~other/ws` names another + * account's home, which is not ours to guess, so it stays a literal path + * instead of silently becoming `$HOME/other/ws`. + */ +function homeRelativeSuffix( + value: string, + platform: NodeJS.Platform +): string | undefined { + if (value === '~') return ''; + if (value.startsWith('~/')) return value.slice(2); + if (platform === 'win32' && value.startsWith('~\\')) return value.slice(2); + return undefined; +} + +/** + * Ask OpenClaw where its workspace is. Config can move it, the environment can + * move it, and a profile changes it again, but that config file is JSON5 and + * out of reach here, so the launcher itself is the authority. Falls back to the + * documented defaults whenever the CLI cannot answer. + */ +function openclawConfiguredWorkspace( + ctx: McpContext, + id: McpLauncherId +): string | undefined { + if (id !== 'openclaw') return undefined; + try { + // Same launcher as `openclaw mcp set`. A raw execFileSync('openclaw') + // throws on Windows .cmd shims, and the catch used to look like a missing + // config value, so the rule landed in the default workspace instead. + const stdout = runClientCommand( + 'openclaw', + ['config', 'get', 'agents.defaults.workspace', '--json'], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + env: cleanNpmEnv(), + // stderr is discarded, so a launcher that wedges or waits on a prompt + // would hang setup with nothing on screen. Same bound as doctor's probe. + timeout: 8000, + } + ); + const value: unknown = JSON.parse(String(stdout)); + if (typeof value !== 'string' || value === '') return undefined; + const suffix = homeRelativeSuffix(value, ctx.platform); + const expanded = suffix === undefined ? value : path.join(ctx.home, suffix); + return path.join(expanded, 'AGENTS.md'); + } catch { + return undefined; } +} - const headers = firecrawlMcpHeaders(resolvedAgent.agent, apiKey, runtimeEnv); - const useGlobal = !options.project && Boolean(options.global); +/** + * Launchers own their MCP configuration, so they are installed through their + * own routine instead of a config write. Failures stay scoped to the one + * launcher: a missing binary must not cost the user the agents that worked. + */ +async function setupMcpLauncher( + id: McpLauncherId, + ctx: McpContext, + runtimeEnv: NodeJS.ProcessEnv, + rules: boolean +): Promise { + const keyless = ctx.auth !== 'env'; + const result: McpClientResult = { + id, + name: mcpTargetName(id), + mcpStatus: 'failed', + mcpDetail: '', + // The mode this run configured, which `keyless` cannot express: it folds + // oauth in with keyless because neither sends a credential. + auth: ctx.auth, + ruleStatus: 'unsupported', + ruleDetail: '', + }; - const args = [ - '-y', - ADD_MCP_PACKAGE, - mcpUrl, - '--name', - 'firecrawl', - '--transport', - 'http', - ]; + try { + switch (id) { + case 'openclaw': + await installOpenClawMcp( + runtimeEnv, + keyless, + true, + ctx.auth === 'oauth' + ); + result.mcpDetail = 'via the openclaw CLI'; + break; + default: { + const unreachable: never = id; + throw new Error(`No installer for launcher ${String(unreachable)}`); + } + } + result.mcpStatus = 'configured'; + } catch (error) { + result.mcpDetail = error instanceof Error ? error.message : String(error); + } - if (headers?.Authorization) { - args.push('--header', `Authorization: ${headers.Authorization}`); + const rule = MCP_LAUNCHER_RULES[id]; + if (!rule) return result; + if (!rules || result.mcpStatus === 'failed') { + // Same dependency as the file-writing agents: a rule that names Firecrawl + // tools is wrong when the server was not registered. + result.ruleStatus = 'skipped'; + return result; } - if (useGlobal) { - args.push('--global'); + if (rule.kind === 'manual') { + const written = await writeConfiguredRule(rule, ''); + result.ruleStatus = written.status; + result.ruleDetail = written.path; + return result; } - if (resolvedAgent.agent) { - args.push('--agent', resolvedAgent.agent); - } else if (resolvedAgent.all) { - args.push('--all'); + const rulePath = openclawConfiguredWorkspace(ctx, id) ?? rule.globalPath(ctx); + // The launcher creates this file itself on first run, seeded with its own + // instructions. Creating it here first would leave the user with our section + // and none of that, so the rule waits for a workspace that exists. + if (!existsSync(rulePath)) { + result.ruleStatus = 'skipped'; + result.ruleDetail = rulePath; + return result; } - if (options.yes) { - args.push('--yes'); + try { + const written = await writeConfiguredRule(rule, rulePath); + result.ruleStatus = written.status; + result.ruleDetail = written.path; + } catch (error) { + result.ruleStatus = 'failed'; + result.ruleDetail = error instanceof Error ? error.message : String(error); + } + return result; +} + +async function confirmMcpRules(): Promise { + const { confirm } = await import('@inquirer/prompts'); + return confirm({ + message: + 'Add rules so agents prefer Firecrawl for web search and scraping?', + default: true, + }); +} + +async function installMcpClients( + options: SetupOptions, + runtimeEnv: NodeJS.ProcessEnv, + explicitIds?: McpTargetId[], + { includeAllLaunchers = false } = {} +): Promise { + const apiKey = options.oauth || options.keyless ? undefined : getApiKey(); + // Sign-in is a different endpoint rather than a different credential, so it + // overrides the key lookup entirely. Otherwise a stored key cannot be written + // into agent config, so authenticated setup requires the variable to be + // exported where the agent will read it. + const auth: McpAuthMode = options.oauth + ? 'oauth' + : isEnvironmentBackedApiKey(apiKey, runtimeEnv) + ? 'env' + : 'keyless'; + + const ctx: McpContext = { + // Resolved so path comparisons hold even for an unnormalized HOME. + home: path.resolve(os.homedir()), + cwd: path.resolve(process.cwd()), + platform: process.platform, + env: runtimeEnv, + auth, + }; + // Prompts only make sense when someone is there to answer them. + const nonInteractive = Boolean(options.yes) || !process.stdin.isTTY; + + // Naming targets is what skips the picker below; nothing here needs `yes`, + // which would also answer the rules prompt on the user's behalf. + let selected = includeAllLaunchers + ? [...ALL_MCP_CLIENT_IDS] + : (explicitIds ?? options.clients); + if (!selected || selected.length === 0) { + const detected: McpTargetId[] = [ + ...(await detectMcpClients(ctx)), + ...detectMcpLaunchers(ctx), + ]; + if (detected.length === 0 && !includeAllLaunchers) { + const message = + 'No coding agents detected. Pass an agent flag such as --claude or --cursor.'; + // Interactive setup needs a picker. Non-interactive `-y` / `setup --yes` + // must not fail the rest of the bundle after skills already installed. + if (!nonInteractive) { + throw new Error(message); + } + if (!options.quiet) console.log(message); + return false; + } + if (nonInteractive) { + selected = detected; + } else { + selected = await pickMcpClients(detected); + if (selected.length === 0) { + console.log('No agents selected. Nothing changed.'); + return false; + } + } } - if (!options.quiet) { - console.log('Configuring Firecrawl MCP...\n'); + // `--agent all` reaches every integration whether or not it looks installed, + // which is what the flag has always meant. + if (includeAllLaunchers) { + selected = [ + ...selected.filter((id) => !isMcpLauncherId(id)), + ...ALL_MCP_LAUNCHER_IDS, + ]; } - try { - runClientCommand('npx', args, { - stdio: 'inherit', - env: cleanNpmEnv(), - }); - if (options.quiet) { - const target = resolvedAgent.agent - ? ` for ${resolvedAgent.agent}` - : resolvedAgent.all - ? ' for launch integrations' - : ''; - console.log(` ${green}✓${reset} Firecrawl MCP configured${target}`); + // `-y` stays MCP-only so automation never rewrites instruction files by + // surprise; the flags are there when a script does want the rules. + const rules = + options.rules ?? (nonInteractive ? false : await confirmMcpRules()); + + const results: McpClientResult[] = []; + for (const id of selected) { + results.push( + isMcpLauncherId(id) + ? await setupMcpLauncher(id, ctx, runtimeEnv, rules) + : await setupMcpClient(id, { rules, ctx }) + ); + } + + reportMcpResults(results, ctx, options, Boolean(apiKey)); + return results.every((result) => result.mcpStatus !== 'failed'); +} + +function ruleLine( + result: McpClientResult, + ctx: McpContext +): string | undefined { + switch (result.ruleStatus) { + case 'installed': + case 'updated': + return ` Rules ${result.ruleStatus} ${dim}${displayPath(result.ruleDetail, ctx)}${reset}`; + case 'skipped': + return ' Rules skipped'; + case 'unsupported': + // Clients only reach this when rules were requested: setupMcpClient + // returns `skipped` whenever rules is false. Hermes has no global rule + // file, so `--rules` has to say so rather than going silent. Cursor's + // global rules are manual, and the next step is in ruleDetail. + return result.ruleDetail + ? ` Rules ${dim}not supported by this agent${reset} ${result.ruleDetail}` + : ` Rules ${dim}not supported by this agent${reset}`; + case 'failed': + return ` ${red}Rules failed${reset} ${result.ruleDetail}`; + default: { + const unreachable: never = result.ruleStatus; + return unreachable; } - } catch { - throw new Error('Failed to configure Firecrawl MCP.'); } } -function installCodexMcpFromEnvironment( +/** + * Explain any gap between the credential the user has and what actually got + * written, so a keyless fallback is never silent. + */ +function authNotes( + results: McpClientResult[], + ctx: McpContext, + hasApiKey: boolean +): string[] { + const succeeded = results.filter((result) => result.mcpStatus !== 'failed'); + if (succeeded.length === 0) return []; + + if (ctx.auth === 'oauth') { + // Deliberately not "each agent prompts you": Codex, Hermes, and OpenClaw + // wait for the command printed above their entry instead. + return ['Sign in from each agent the first time you use it.']; + } + + if (!hasApiKey) { + return [ + `Running keyless (search, scrape, parse). Export ${ENV_API_KEY} where your agents run, then rerun to authenticate.`, + ]; + } + + if (ctx.auth !== 'env') { + return [ + `Configured keyless: your stored key is never written into agent config. Export ${ENV_API_KEY} where your agents run, then rerun to authenticate.`, + ]; + } + + return []; +} + +/** + * The command that signs this agent in. Only the agents that need one say + * anything: the rest prompt on their own, and the footer already tells the + * person to expect that. + */ +function signInLine( + result: McpClientResult, + ctx: McpContext +): string | undefined { + if (ctx.auth !== 'oauth' || result.mcpStatus === 'failed') return undefined; + const spec = isMcpLauncherId(result.id) + ? MCP_LAUNCHER_OAUTH[result.id] + : MCP_CLIENTS[result.id].oauth; + return spec?.nextStep + ? ` Sign in: ${dim}${spec.nextStep}${reset}` + : undefined; +} + +function reportMcpResults( + results: McpClientResult[], + ctx: McpContext, options: SetupOptions, - mcpUrl: string + hasApiKey: boolean ): void { - if (!options.quiet) { - console.log('Configuring Firecrawl MCP...\n'); + const succeeded = results.filter((result) => result.mcpStatus !== 'failed'); + + if (options.quiet) { + for (const result of results) { + console.log( + result.mcpStatus === 'failed' + ? ` ${red}✗${reset} Firecrawl MCP failed for ${result.name}: ${result.mcpDetail}` + : ` ${green}✓${reset} Firecrawl MCP configured for ${result.name}` + ); + } + for (const note of authNotes(results, ctx, hasApiKey)) { + console.log(` ${dim}${note}${reset}`); + } + if (succeeded.length === 0) { + throw new Error('Failed to configure Firecrawl MCP.'); + } + return; } - try { - runClientCommand( - 'codex', - [ - 'mcp', - 'add', - 'firecrawl', - '--url', - mcpUrl, - '--bearer-token-env-var', - 'FIRECRAWL_API_KEY', - ], - { stdio: 'inherit', env: cleanNpmEnv() } + for (const result of results) { + console.log(`${bold}${result.name}${reset}`); + console.log( + result.mcpStatus === 'failed' + ? ` ${red}MCP failed${reset} ${result.mcpDetail}` + : ` MCP ${result.mcpStatus} ${dim}${displayPath(result.mcpDetail, ctx)}${reset}` + ); + const signIn = signInLine(result, ctx); + if (signIn) console.log(signIn); + const rules = ruleLine(result, ctx); + if (rules) console.log(rules); + } + + console.log(''); + console.log( + `Firecrawl MCP set up for ${succeeded.length}/${results.length} agents. Restart your agents to load it.` + ); + for (const note of authNotes(results, ctx, hasApiKey)) { + console.log(`${dim}${note}${reset}`); + } + + // Every agent that could be configured was, but the caller asked for these + // agents and did not get them all. Quiet mode is embedded in a larger command + // that reports its own outcome, so it still fails only when nothing landed. + const failed = results.filter((result) => result.mcpStatus === 'failed'); + if (failed.length > 0) { + throw new Error( + `Firecrawl MCP failed for ${failed.map((result) => result.name).join(', ')}.` ); - if (options.quiet) { - console.log(` ${green}✓${reset} Firecrawl MCP configured for codex`); - } - } catch { - throw new Error('Failed to configure Firecrawl MCP for Codex.'); } } function firecrawlMcpConfig( agent?: string, runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false + keyless = false, + oauth = false ): { url: string; headers?: Record; transport?: string; } { return { - url: firecrawlHostedMcpUrl(), + url: firecrawlHostedMcpUrl(oauth), + // Sign-in replaces the credential rather than travelling beside it, so the + // key is dropped here too. Callers already choose one or the other, but a + // helper this public must not put credential configuration on the sign-in + // endpoint just because it was called directly. headers: firecrawlMcpHeaders( agent, - keyless ? undefined : getApiKey(), + keyless || oauth ? undefined : getApiKey(), runtimeEnv ), }; } -export async function installHermesMcp( - runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false -): Promise { - const config = firecrawlMcpConfig('hermes', runtimeEnv, keyless); - const configPath = path.join(os.homedir(), '.hermes', 'config.yaml'); - mkdirSync(path.dirname(configPath), { recursive: true }); - - const existing = existsSync(configPath) - ? readFileSync(configPath, 'utf-8') - : ''; - const root = (parseYaml(existing || '{}') ?? {}) as Record; - const mcpServers = - typeof root.mcp_servers === 'object' && - root.mcp_servers !== null && - !Array.isArray(root.mcp_servers) - ? (root.mcp_servers as Record) - : {}; - - mcpServers.firecrawl = config; - root.mcp_servers = mcpServers; - writeFileSync(configPath, stringifyYaml(root), { - encoding: 'utf-8', - mode: 0o600, - }); - if (process.platform !== 'win32') { - chmodSync(configPath, 0o600); - } - console.log(`Hermes Agent MCP configured at ${configPath}.`); -} - export async function installOpenClawMcp( runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false + keyless = false, + /** Suppress standalone logging when a caller renders its own summary. */ + quiet = false, + oauth = false ): Promise { const config = { - ...firecrawlMcpConfig('openclaw', runtimeEnv, keyless), + ...firecrawlMcpConfig('openclaw', runtimeEnv, keyless, oauth), transport: 'streamable-http', + ...(oauth ? MCP_LAUNCHER_OAUTH.openclaw?.entry : undefined), }; - console.log('Configuring Firecrawl MCP for OpenClaw...\n'); + if (!quiet) console.log('Configuring Firecrawl MCP for OpenClaw...\n'); try { runClientCommand( diff --git a/src/commands/skills-native.ts b/src/commands/skills-native.ts index c899d17e02..acd8db5767 100644 --- a/src/commands/skills-native.ts +++ b/src/commands/skills-native.ts @@ -193,6 +193,11 @@ function resolveAgentConfig(agent: string): AgentConfig | undefined { return AGENTS.find((candidate) => candidate.name === normalized); } +/** True when this name is a supported skills target, whatever else supports it. */ +export function isSkillsAgentName(agent: string): boolean { + return resolveAgentConfig(agent) !== undefined; +} + /** * Discover all skills in a directory tree by finding SKILL.md files. */ diff --git a/src/index.ts b/src/index.ts index 87ecfee452..988ae59069 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,6 +60,7 @@ import { } from './commands/init'; import { handleMakeDefaultCommand, handleSetupCommand } from './commands/setup'; import type { SetupSubcommand } from './commands/setup'; +import { ALL_MCP_TARGET_IDS, mcpTargetName } from './utils/mcp-clients'; import { handleEnvPullCommand } from './commands/env'; import { handleStatusCommand } from './commands/status'; import { handleDoctorCommand } from './commands/doctor'; @@ -2232,7 +2233,7 @@ program }); }); -program +const setupCommand = program .command('setup') .description( 'Set up individual firecrawl integrations (skills, workflows, mcp, defaults)' @@ -2241,14 +2242,9 @@ program '[subcommand]', 'What to set up: "skills", "workflows", "mcp", or "defaults"; omit for an interactive installer' ) - .option('-g, --global', 'Install globally (user-level)') - .option( - '--project', - 'For "mcp", install into project scope (stored API keys are never written to project files)' - ) .option( '-a, --agent ', - 'Limit to a specific agent; required for environment-backed MCP setup, or use "all" to update every launch integration' + 'Limit to a specific agent, or use "all" to update every supported agent rather than only the detected ones' ) .option( '-y, --yes', @@ -2258,12 +2254,47 @@ program '--keyless', 'Configure anonymous hosted MCP even when an API key is stored' ) + .option('--oauth', 'Point agents at the sign-in endpoint instead (mcp)') .option( '--undo', 'Undo setup defaults by re-enabling native web tools where supported' + ); + +// Per-agent flags for `setup mcp`, so scripts can skip the picker. +for (const id of ALL_MCP_TARGET_IDS) { + setupCommand.option(`--${id}`, `Set up ${mcpTargetName(id)} (mcp)`); +} + +// `-g` is the old way to ask for the global scope that is now the default. +// Kept so existing scripts keep running, hidden because it does nothing. +setupCommand.addOption( + new Option('-g, --global', 'Deprecated; global is the default').hideHelp() +); + +setupCommand + .option('--rules', 'Install rules that prefer Firecrawl for web work (mcp)') + .option('--no-rules', 'Skip the rules prompt and install MCP only (mcp)') + .addHelpText( + 'after', + ` +Examples: + $ firecrawl setup mcp # pick agents, then choose rules + $ firecrawl setup mcp --oauth # sign in from each agent's browser + $ firecrawl setup mcp --claude --cursor # skip the picker + $ firecrawl setup mcp --yes # every detected agent, MCP only + $ firecrawl setup mcp --yes --rules # every detected agent, with rules +` ) .action(async (subcommand: SetupSubcommand, options) => { - await handleSetupCommand(subcommand, options); + if (options.global) { + console.error( + 'Note: -g/--global is deprecated for setup. Global is the default.' + ); + } + await handleSetupCommand(subcommand, { + ...options, + clients: ALL_MCP_TARGET_IDS.filter((id) => options[id] === true), + }); }); program diff --git a/src/utils/agents.ts b/src/utils/agents.ts index 0ba6ea4aec..d6799a9b3f 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -3,13 +3,25 @@ * registered with them. Used by `firecrawl doctor`. * * Detection is best-effort: presence of the config dir/file is treated as - * "installed". MCP registration is detected by parsing the JSON config and - * looking for an entry named `firecrawl` in `mcpServers`. + * "installed". MCP registration for setup-supported agents uses the same + * path helpers as `mcp-clients.ts`. OpenClaw is verified through + * `openclaw mcp show firecrawl --json`. */ import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; +import { parse as parseJsonc } from 'jsonc-parser'; +import { parseDocument } from 'yaml'; +import { + createMcpContext, + detectMcpLaunchers, + MCP_CLIENTS, + type McpClientId, + type McpContext, +} from './mcp-clients'; +import { tomlHasServer } from './mcp-install'; +import { runClientCommand } from './run-client-command'; export type AgentId = | 'cursor' @@ -18,6 +30,9 @@ export type AgentId = | 'vscode' | 'windsurf' | 'codex' + | 'opencode' + | 'hermes' + | 'openclaw' | 'continue'; export interface AgentDetection { @@ -35,14 +50,26 @@ interface AgentSpec { name: string; /** Files/dirs that indicate the agent is installed. */ presencePaths: () => string[]; - /** Config files to scan for an `mcpServers.firecrawl` entry. */ + /** Config files to scan for a Firecrawl MCP server entry. */ mcpConfigPaths: (cwd: string) => string[]; + /** When set, used instead of scanning presencePaths. */ + isInstalled?: () => boolean; + /** When set, used instead of scanning mcpConfigPaths. */ + probeRegistered?: () => boolean; } -const home = os.homedir(); const platform = os.platform(); +function homedir(): string { + return os.homedir(); +} + +function doctorContext(): McpContext { + return createMcpContext(); +} + function appSupportDir(name: string): string { + const home = homedir(); if (platform === 'darwin') { return path.join(home, 'Library', 'Application Support', name); } @@ -55,28 +82,53 @@ function appSupportDir(name: string): string { return path.join(home, '.config', name); } +function fromClient( + id: AgentId, + clientId: McpClientId, + extraConfigPaths?: (cwd: string, ctx: McpContext) => string[] +): AgentSpec { + return { + id, + name: MCP_CLIENTS[clientId].name, + presencePaths: () => MCP_CLIENTS[clientId].detectPaths(doctorContext()), + mcpConfigPaths: (cwd) => { + const ctx = doctorContext(); + return [ + MCP_CLIENTS[clientId].globalConfigPath(ctx), + ...(extraConfigPaths?.(cwd, ctx) ?? []), + ]; + }, + }; +} + +/** + * OpenClaw's documented registry interface is the CLI, not the JSON5 config + * file. Exit 0 plus a JSON object means the server is registered. + */ +export function openclawFirecrawlRegistered(): boolean { + try { + const stdout = runClientCommand( + 'openclaw', + ['mcp', 'show', 'firecrawl', '--json'], + { + encoding: 'utf8', + timeout: 8000, + stdio: ['ignore', 'pipe', 'ignore'], + } + ); + const parsed: unknown = JSON.parse(String(stdout)); + if (!parsed || typeof parsed !== 'object') return false; + return (parsed as { ok?: unknown }).ok !== false; + } catch { + return false; + } +} + const SPECS: AgentSpec[] = [ - { - id: 'cursor', - name: 'Cursor', - presencePaths: () => [path.join(home, '.cursor')], - mcpConfigPaths: (cwd) => [ - path.join(home, '.cursor', 'mcp.json'), - path.join(cwd, '.cursor', 'mcp.json'), - ], - }, - { - id: 'claude-code', - name: 'Claude Code', - presencePaths: () => [ - path.join(home, '.claude'), - path.join(home, '.claude.json'), - ], - mcpConfigPaths: (cwd) => [ - path.join(home, '.claude.json'), - path.join(cwd, '.mcp.json'), - ], - }, + fromClient('cursor', 'cursor', (cwd) => [ + path.join(cwd, '.cursor', 'mcp.json'), + ]), + fromClient('claude-code', 'claude', (cwd) => [path.join(cwd, '.mcp.json')]), { id: 'claude-desktop', name: 'Claude Desktop', @@ -85,42 +137,46 @@ const SPECS: AgentSpec[] = [ path.join(appSupportDir('Claude'), 'claude_desktop_config.json'), ], }, - { - id: 'vscode', - name: 'VS Code', - presencePaths: () => [appSupportDir('Code'), path.join(home, '.vscode')], - mcpConfigPaths: (cwd) => [ - path.join(appSupportDir('Code'), 'User', 'mcp.json'), - path.join(appSupportDir('Code'), 'User', 'settings.json'), - path.join(cwd, '.vscode', 'mcp.json'), - ], - }, + fromClient('vscode', 'vscode', (cwd, ctx) => [ + path.join( + path.dirname(MCP_CLIENTS.vscode.globalConfigPath(ctx)), + 'settings.json' + ), + path.join(cwd, '.vscode', 'mcp.json'), + ]), { id: 'windsurf', name: 'Windsurf', presencePaths: () => [ - path.join(home, '.codeium', 'windsurf'), - path.join(home, '.windsurf'), + path.join(homedir(), '.codeium', 'windsurf'), + path.join(homedir(), '.windsurf'), ], mcpConfigPaths: () => [ - path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), + path.join(homedir(), '.codeium', 'windsurf', 'mcp_config.json'), ], }, + fromClient('codex', 'codex', (_cwd, ctx) => [ + path.join( + path.dirname(MCP_CLIENTS.codex.globalConfigPath(ctx)), + 'mcp.json' + ), + ]), + fromClient('opencode', 'opencode'), + fromClient('hermes', 'hermes'), { - id: 'codex', - name: 'Codex', - presencePaths: () => [path.join(home, '.codex')], - mcpConfigPaths: () => [ - path.join(home, '.codex', 'config.toml'), - path.join(home, '.codex', 'mcp.json'), - ], + id: 'openclaw', + name: 'OpenClaw', + presencePaths: () => [path.join(homedir(), '.openclaw')], + isInstalled: () => detectMcpLaunchers(doctorContext()).includes('openclaw'), + mcpConfigPaths: () => [], + probeRegistered: openclawFirecrawlRegistered, }, { id: 'continue', name: 'Continue', - presencePaths: () => [path.join(home, '.continue')], + presencePaths: () => [path.join(homedir(), '.continue')], mcpConfigPaths: (cwd) => [ - path.join(home, '.continue', 'config.json'), + path.join(homedir(), '.continue', 'config.json'), path.join(cwd, '.continue', 'config.json'), ], }, @@ -137,66 +193,71 @@ async function pathExists(p: string): Promise { async function fileHasFirecrawlMcp(filePath: string): Promise { try { - const content = await fs.readFile(filePath, 'utf8'); + const stored = await fs.readFile(filePath, 'utf8'); + const content = stored.startsWith('\uFEFF') ? stored.slice(1) : stored; - // TOML configs (Codex) — cheap substring check, good enough for a doctor - // check that just needs a yes/no signal. if (filePath.endsWith('.toml')) { - return /\[mcp_servers?\.firecrawl\]/i.test(content); + return tomlHasServer(content, 'firecrawl'); } - // JSON-ish configs. Some tools (VS Code settings.json) allow comments — - // strip them before parsing. - const stripped = content - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:\\])\/\/.*$/gm, '$1'); - - let parsed: unknown; - try { - parsed = JSON.parse(stripped); - } catch { - // Last resort: substring scan. Avoids false negatives when a config - // uses an unusual JSON dialect. - return /"firecrawl"\s*:/.test(content) && /mcpServers?/i.test(content); + if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) { + const doc = parseDocument(content); + return doc.errors.length === 0 && doc.hasIn(['mcp_servers', 'firecrawl']); } - return hasFirecrawlMcpEntry(parsed); + // Recoverable JSONC errors (a missing comma, trailing junk) still yield a + // tree the agent will load. Only the recovered value decides registration. + return hasFirecrawlMcpEntry( + parseJsonc(content, [], { allowTrailingComma: true }) + ); } catch { return false; } } +/** True when `value` is a server map holding an entry named `firecrawl`. */ +function isFirecrawlServerMap(value: unknown): boolean { + return ( + !!value && + typeof value === 'object' && + Object.prototype.hasOwnProperty.call(value, 'firecrawl') + ); +} + /** - * Walk a parsed JSON config looking for an `mcpServers` (or `mcp.servers`) - * map that contains a `firecrawl` key. Exported for testing. + * Claude Code keeps a per-project server map under `projects`, so `mcpServers` + * is the one key that has to be matched at any depth. + */ +function hasNestedMcpServers(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const obj = value as Record; + + if (isFirecrawlServerMap(obj.mcpServers)) return true; + return Object.values(obj).some(hasNestedMcpServers); +} + +/** + * Walk a parsed JSON config looking for a server map that contains a + * `firecrawl` key. Exported for testing. */ export function hasFirecrawlMcpEntry(value: unknown): boolean { if (!value || typeof value !== 'object') return false; const obj = value as Record; - for (const key of Object.keys(obj)) { - const child = obj[key]; - if (key === 'mcpServers' && child && typeof child === 'object') { - if (Object.prototype.hasOwnProperty.call(child, 'firecrawl')) { - return true; - } - } - if (key === 'mcp' && child && typeof child === 'object') { - const mcp = child as Record; - const servers = mcp.servers; - if ( - servers && - typeof servers === 'object' && - Object.prototype.hasOwnProperty.call(servers, 'firecrawl') - ) { - return true; - } - } - if (child && typeof child === 'object') { - if (hasFirecrawlMcpEntry(child)) return true; + // VS Code (`servers`, or `mcp.servers` in settings.json) and OpenCode (`mcp`) + // both keep their map at the root. Matching those keys at any depth would let + // an unrelated nested object that happens to hold a `firecrawl` property + // report the server as registered when it is not. + if (isFirecrawlServerMap(obj.servers) || isFirecrawlServerMap(obj.mcp)) { + return true; + } + if (obj.mcp && typeof obj.mcp === 'object') { + if (isFirecrawlServerMap((obj.mcp as Record).servers)) { + return true; } } - return false; + + return hasNestedMcpServers(obj); } /** @@ -207,17 +268,24 @@ export async function detectAgents( ): Promise { return Promise.all( SPECS.map(async (spec) => { - const presence = await Promise.all(spec.presencePaths().map(pathExists)); - const installed = presence.some(Boolean); + const installed = spec.isInstalled + ? spec.isInstalled() + : (await Promise.all(spec.presencePaths().map(pathExists))).some( + Boolean + ); const configPaths = spec.mcpConfigPaths(cwd); let mcpRegistered = false; if (installed) { - for (const cfg of configPaths) { - // eslint-disable-next-line no-await-in-loop - if (await fileHasFirecrawlMcp(cfg)) { - mcpRegistered = true; - break; + if (spec.probeRegistered) { + mcpRegistered = spec.probeRegistered(); + } else { + for (const cfg of configPaths) { + // eslint-disable-next-line no-await-in-loop + if (await fileHasFirecrawlMcp(cfg)) { + mcpRegistered = true; + break; + } } } } diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts new file mode 100644 index 0000000000..589916f39c --- /dev/null +++ b/src/utils/mcp-clients.ts @@ -0,0 +1,551 @@ +/** + * Registry of AI coding agents that can host the hosted Firecrawl MCP server. + * + * Every agent reads a config file that maps a server name to a connection + * entry, but the file location, the key holding that map, and the shape of the + * entry itself differ per agent. This module is the single place those + * differences live; `mcp-install.ts` does the writing. + * + * Credentials are never handled here. A stored API key must not end up as a + * literal in a config file, so this module only ever emits an indirect + * reference to `FIRECRAWL_API_KEY` using the syntax a given agent is known to + * expand. An agent is only supported once that syntax is verified. + */ + +import { + accessSync, + constants as fsConstants, + existsSync, + promises as fs, + statSync, +} from 'fs'; +import os from 'os'; +import path from 'path'; + +export const FIRECRAWL_MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; +/** + * Browser sign-in endpoint. A different server URL, not a different header, so + * choosing it is what puts an agent into the sign-in flow. + */ +export const FIRECRAWL_MCP_OAUTH_URL = 'https://mcp.firecrawl.dev/v2/mcp-oauth'; +export const MCP_SERVER_NAME = 'firecrawl'; +export const API_KEY_ENV_VAR = 'FIRECRAWL_API_KEY'; + +export type McpClientId = + | 'claude' + | 'cursor' + | 'vscode' + | 'codex' + | 'opencode' + | 'hermes'; + +/** + * Agent launchers that own their MCP configuration rather than reading a file + * we write. They are offered alongside the editors but installed differently. + * + * OpenClaw is the only one: its config is JSON5, which the editor we patch JSON + * with cannot read, and `openclaw mcp set` is the vendor-documented path that + * also normalises the entry. Hermes reads plain YAML, so it is a client. + */ +export type McpLauncherId = 'openclaw'; + +export type McpTargetId = McpClientId | McpLauncherId; + +/** + * `env` writes an indirect reference to `FIRECRAWL_API_KEY`, which only works + * when that variable is exported in the environment the agent runs under. + * `keyless` writes no credential at all. `oauth` writes no credential either + * and points the agent at the sign-in endpoint, which it authenticates against + * through a browser flow the person completes in the agent itself. + */ +export type McpAuthMode = 'env' | 'keyless' | 'oauth'; + +export interface McpContext { + home: string; + cwd: string; + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + auth: McpAuthMode; +} + +export type McpRuleSpec = + | { + /** + * `file` owns a dedicated rule file and rewrites it wholesale. `append` + * shares a file with the user's own instructions, so the section is fenced + * by markers and replaced in place on rerun. + */ + kind: 'file' | 'append'; + content: string; + globalPath: (ctx: McpContext) => string; + } + | { + /** + * The agent has global rules, but they are not a file we can write. + * `--rules` reports this instead of claiming a filesystem install. + */ + kind: 'manual'; + nextStep: string; + }; + +/** Process-backed context for setup, detection, and doctor. */ +export function createMcpContext( + overrides: Partial = {} +): McpContext { + return { + home: overrides.home ?? os.homedir(), + cwd: overrides.cwd ?? process.cwd(), + platform: overrides.platform ?? process.platform, + env: overrides.env ?? process.env, + auth: overrides.auth ?? 'keyless', + }; +} + +function envOverride(env: NodeJS.ProcessEnv, name: string): string | undefined { + const value = env[name]; + return value && value !== '' ? value : undefined; +} + +/** Codex reads `$CODEX_HOME` when set, otherwise `~/.codex`. */ +export function codexHome(ctx: McpContext): string { + return envOverride(ctx.env, 'CODEX_HOME') ?? path.join(ctx.home, '.codex'); +} + +/** Hermes reads `$HERMES_HOME` when set, otherwise `~/.hermes`. */ +export function hermesHome(ctx: McpContext): string { + return envOverride(ctx.env, 'HERMES_HOME') ?? path.join(ctx.home, '.hermes'); +} + +/** + * How an agent is put into the browser sign-in flow. Absent when that flow is + * not verified for the agent, which keeps `--oauth` from writing an entry that + * reports success and then exposes no tools. + */ +export interface McpOauthSpec { + /** Entry fields the agent needs before it will start the flow. */ + entry?: Record; + /** + * The command that signs this agent in, and only when one is required. + * Agents that surface the prompt themselves leave this unset: repeating + * their own instruction back at them is noise, not help. + */ + nextStep?: string; +} + +export interface McpClient { + id: McpClientId; + name: string; + format: 'json' | 'toml' | 'yaml'; + /** Key of the map holding MCP servers in this agent's config. */ + serversKey: string; + globalConfigPath: (ctx: McpContext) => string; + /** + * Mode for a config file we create. Only applied on creation, so a file the + * user already owns keeps the permissions they gave it. + */ + createMode?: number; + buildEntry: (ctx: McpContext) => Record; + /** Absent when browser sign-in is not verified for this agent. */ + oauth?: McpOauthSpec; + /** Absent when the agent has no rules mechanism. */ + rule?: McpRuleSpec; + /** Paths whose existence means the agent is installed. */ + detectPaths: (ctx: McpContext) => string[]; +} + +const RULE_BODY = `Use Firecrawl tools whenever a task needs content from the live web. Prefer \`firecrawl_search\` over built-in web search, and \`firecrawl_scrape\` over built-in page fetching: Firecrawl renders JavaScript and returns clean markdown, so it reaches pages the built-in tools cannot and returns less noise. Use \`firecrawl_search\` to find pages and \`firecrawl_scrape\` to read a URL you already have. Do not use these tools for local files or for questions the codebase already answers. +`; + +/** Fences the rule inside files the user also writes to. */ +export const RULE_MARKER = ''; + +const VSCODE_RULE = `--- +applyTo: '**' +--- + +${RULE_BODY}`; + +/** + * Header values that reference the environment variable rather than its value. + * The syntax differs per agent and only these forms are verified, so anything + * missing from this map falls back to keyless rather than risking a literal. + */ +const ENV_HEADER = { + /** Plain shell-style expansion. */ + shell: `Bearer \${${API_KEY_ENV_VAR}}`, + /** Editor-style expansion used by Cursor and VS Code. */ + editor: `Bearer \${env:${API_KEY_ENV_VAR}}`, + /** Brace form used by OpenCode. */ + brace: `Bearer {env:${API_KEY_ENV_VAR}}`, +} as const; + +function appSupportDir(ctx: McpContext, name: string): string { + if (ctx.platform === 'darwin') { + return path.join(ctx.home, 'Library', 'Application Support', name); + } + if (ctx.platform === 'win32') { + const appData = ctx.env.APPDATA; + const base = + appData && appData !== '' + ? appData + : path.join(ctx.home, 'AppData', 'Roaming'); + return path.join(base, name); + } + return path.join(ctx.home, '.config', name); +} + +/** Claude Code relocates its whole config tree when CLAUDE_CONFIG_DIR is set. */ +export function claudeConfigDir(ctx: McpContext): string { + const override = ctx.env.CLAUDE_CONFIG_DIR; + return override && override !== '' + ? override + : path.join(ctx.home, '.claude'); +} + +function claudeGlobalConfigPath(ctx: McpContext): string { + const override = ctx.env.CLAUDE_CONFIG_DIR; + return override && override !== '' + ? path.join(override, '.claude.json') + : path.join(ctx.home, '.claude.json'); +} + +function vscodeUserDir(ctx: McpContext): string { + return path.join(appSupportDir(ctx, 'Code'), 'User'); +} + +/** Sign-in uses a separate endpoint, so the URL follows the auth mode. */ +export function firecrawlMcpUrl(ctx: McpContext): string { + return ctx.auth === 'oauth' ? FIRECRAWL_MCP_OAUTH_URL : FIRECRAWL_MCP_URL; +} + +/** Attach the agent's env-reference header when authenticating that way. */ +function withEnvAuth( + ctx: McpContext, + entry: Record, + header: string +): Record { + if (ctx.auth !== 'env') return entry; + return { ...entry, headers: { Authorization: header } }; +} + +export const MCP_CLIENTS: Record = { + claude: { + id: 'claude', + name: 'Claude Code', + format: 'json', + serversKey: 'mcpServers', + globalConfigPath: claudeGlobalConfigPath, + buildEntry: (ctx) => + withEnvAuth( + ctx, + { type: 'http', url: firecrawlMcpUrl(ctx) }, + ENV_HEADER.shell + ), + rule: { + kind: 'file', + content: RULE_BODY, + globalPath: (ctx) => + path.join(claudeConfigDir(ctx), 'rules', 'firecrawl.md'), + }, + // Claude Code flags a server that answers 401 and shows a startup notice + // pointing at `/mcp`, so setup has nothing to add. + detectPaths: (ctx) => [claudeConfigDir(ctx), claudeGlobalConfigPath(ctx)], + }, + cursor: { + id: 'cursor', + name: 'Cursor', + format: 'json', + serversKey: 'mcpServers', + globalConfigPath: (ctx) => path.join(ctx.home, '.cursor', 'mcp.json'), + buildEntry: (ctx) => + withEnvAuth(ctx, { url: firecrawlMcpUrl(ctx) }, ENV_HEADER.editor), + // Cursor documents `.cursor/rules` as project-scoped. Global User Rules + // live in Customize → Rules and are not a file we can write. + rule: { + kind: 'manual', + nextStep: 'Add it in Cursor under Customize → Rules', + }, + // Cursor marks the server as needing login in its own MCP settings. + detectPaths: (ctx) => [path.join(ctx.home, '.cursor')], + }, + vscode: { + id: 'vscode', + name: 'VS Code', + format: 'json', + serversKey: 'servers', + globalConfigPath: (ctx) => path.join(vscodeUserDir(ctx), 'mcp.json'), + buildEntry: (ctx) => + withEnvAuth( + ctx, + { type: 'http', url: firecrawlMcpUrl(ctx) }, + ENV_HEADER.editor + ), + rule: { + kind: 'file', + content: VSCODE_RULE, + globalPath: (ctx) => + path.join( + ctx.home, + '.copilot', + 'instructions', + 'firecrawl.instructions.md' + ), + }, + // `User` is created on first launch, so requiring it misses an install + // that has only been unpacked. These are the markers doctor already uses. + // VS Code registers its own client and opens the browser when the server + // starts, and documents no sign-in command to point at. + detectPaths: (ctx) => [ + appSupportDir(ctx, 'Code'), + path.join(ctx.home, '.vscode'), + ], + }, + codex: { + id: 'codex', + name: 'Codex', + format: 'toml', + serversKey: 'mcp_servers', + globalConfigPath: (ctx) => path.join(codexHome(ctx), 'config.toml'), + // Codex resolves the bearer token from the environment by variable name, + // so it authenticates without a header template. + buildEntry: (ctx) => + ctx.auth === 'env' + ? { url: firecrawlMcpUrl(ctx), bearer_token_env_var: API_KEY_ENV_VAR } + : { url: firecrawlMcpUrl(ctx) }, + rule: { + kind: 'append', + content: RULE_BODY, + globalPath: (ctx) => path.join(codexHome(ctx), 'AGENTS.md'), + }, + // Codex registers the server but does not start the flow on its own. The + // desktop app and the IDE extension share this config file and offer an + // Authenticate action; only the CLI needs the command. + oauth: { + nextStep: 'codex mcp login firecrawl, or Authenticate in Codex settings', + }, + detectPaths: (ctx) => [codexHome(ctx)], + }, + opencode: { + id: 'opencode', + name: 'OpenCode', + format: 'json', + serversKey: 'mcp', + globalConfigPath: (ctx) => + path.join(ctx.home, '.config', 'opencode', 'opencode.json'), + buildEntry: (ctx) => + withEnvAuth( + ctx, + { type: 'remote', url: firecrawlMcpUrl(ctx), enabled: true }, + ENV_HEADER.brace + ), + rule: { + kind: 'append', + content: RULE_BODY, + globalPath: (ctx) => + path.join(ctx.home, '.config', 'opencode', 'AGENTS.md'), + }, + // OpenCode prompts on first use, so there is nothing to tell the user. + detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], + }, + hermes: { + id: 'hermes', + name: 'Hermes Agent', + format: 'yaml', + serversKey: 'mcp_servers', + globalConfigPath: (ctx) => path.join(hermesHome(ctx), 'config.yaml'), + // Hermes keeps secrets in ~/.hermes/.env rather than here, but the rest of + // this file is the user's, so a file we create starts owner-only. + createMode: 0o600, + // Documented HTTP server shape: `url` plus a `headers` mapping. Hermes + // expands `${VAR}` in any string value in a server entry. + buildEntry: (ctx) => + withEnvAuth(ctx, { url: firecrawlMcpUrl(ctx) }, ENV_HEADER.shell), + // No `rule`: Hermes reads AGENTS.md from the project directory, and setup + // only ever writes global config, so there is no global rule file to own. + // Hermes only starts the flow when the entry opts into it. A running + // session reloads this file on a 30s timer, which is not long enough to + // finish the flow, so the login command has to run outside that session. + oauth: { + entry: { auth: 'oauth' }, + nextStep: 'hermes mcp login firecrawl, from a new terminal', + }, + detectPaths: (ctx) => [hermesHome(ctx)], + }, +}; + +export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ + 'claude', + 'cursor', + 'vscode', + 'codex', + 'opencode', + 'hermes', +]; + +export const MCP_LAUNCHER_NAMES: Record = { + openclaw: 'OpenClaw', +}; + +/** + * OpenClaw keeps its bootstrap files in a workspace directory, which the user + * can move. An explicit config value wins over the environment, but that config + * is JSON5 and out of reach here, so this covers the documented defaults only. + */ +function openclawWorkspaceDir(ctx: McpContext): string { + const explicit = ctx.env.OPENCLAW_WORKSPACE_DIR; + if (explicit && explicit !== '') return explicit; + const profile = ctx.env.OPENCLAW_PROFILE; + const suffix = + profile && profile !== '' && profile !== 'default' ? `-${profile}` : ''; + return path.join(ctx.home, '.openclaw', `workspace${suffix}`); +} + +/** + * A launcher owns its MCP registration but can still read an instruction file + * we write. OpenClaw injects its workspace `AGENTS.md` into the system prompt + * on every turn, so the rule belongs there, fenced like any shared file. + */ +/** Sign-in support for launchers, held apart because they take no config write. */ +export const MCP_LAUNCHER_OAUTH: Partial> = + { + openclaw: { + // A static Authorization header is ignored once this is set, and the + // login command only runs for servers configured with it. + entry: { auth: 'oauth' }, + nextStep: 'openclaw mcp login firecrawl', + }, + }; + +export const MCP_LAUNCHER_RULES: Partial> = { + openclaw: { + kind: 'append', + content: RULE_BODY, + globalPath: (ctx) => path.join(openclawWorkspaceDir(ctx), 'AGENTS.md'), + }, +}; + +export const ALL_MCP_LAUNCHER_IDS: readonly McpLauncherId[] = ['openclaw']; + +export const ALL_MCP_TARGET_IDS: readonly McpTargetId[] = [ + ...ALL_MCP_CLIENT_IDS, + ...ALL_MCP_LAUNCHER_IDS, +]; + +export function isMcpLauncherId(id: McpTargetId): id is McpLauncherId { + return (ALL_MCP_LAUNCHER_IDS as readonly string[]).includes(id); +} + +export function mcpTargetName(id: McpTargetId): string { + return isMcpLauncherId(id) ? MCP_LAUNCHER_NAMES[id] : MCP_CLIENTS[id].name; +} + +/** + * Look for an executable across PATH without spawning it. Launchers are CLIs, + * so their presence on PATH is the signal, but running `--version` during a + * picker would be slow and have side effects. + * + * Existence is not enough: a leftover non-executable file or a directory of + * the same name would put OpenClaw in the picker on a machine that cannot + * run it. Windows treats PATHEXT-matched files as launchable; POSIX needs + * the execute bit. + */ +function isRunnablePath(candidate: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(candidate).isFile()) return false; + if (platform === 'win32') return true; + accessSync(candidate, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +function binaryOnPath(name: string, ctx: McpContext): boolean { + const extensions = + ctx.platform === 'win32' + ? (ctx.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) + : ['']; + const entries = (ctx.env.PATH ?? ctx.env.Path ?? '') + .split(path.delimiter) + .filter(Boolean); + for (const entry of entries) { + for (const extension of extensions) { + if (isRunnablePath(path.join(entry, `${name}${extension}`), ctx.platform)) + return true; + } + } + return false; +} + +/** + * Detection prefers a false negative to a false positive: the picker only + * lists agents that look installed, so a miss means the user passes a flag + * (`--cursor`) instead of seeing an agent they do not have. + * + * Hermes is detected by its config directory alone, through `detectPaths`. Its + * name is also used by an unrelated JavaScript engine that ships with common + * toolchains, so a PATH lookup reports it present on machines without it. + */ +const LAUNCHER_DETECT: Record boolean> = { + openclaw: (ctx) => + existsSync(path.join(ctx.home, '.openclaw')) || + binaryOnPath('openclaw', ctx), +}; + +/** Launchers present on this machine, in registry order. */ +export function detectMcpLaunchers(ctx: McpContext): McpLauncherId[] { + return ALL_MCP_LAUNCHER_IDS.filter((id) => LAUNCHER_DETECT[id](ctx)); +} + +/** Aliases accepted by `--agent`, including the names `firecrawl launch` uses. */ +const CLIENT_ALIASES: Record = { + claude: 'claude', + 'claude-code': 'claude', + claudecode: 'claude', + cursor: 'cursor', + vscode: 'vscode', + 'vs-code': 'vscode', + code: 'vscode', + codex: 'codex', + 'codex-app': 'codex', + 'codex-desktop': 'codex', + 'codex-gui': 'codex', + opencode: 'opencode', + 'open-code': 'opencode', + hermes: 'hermes', + 'hermes-agent': 'hermes', +}; + +export function resolveMcpClientId(agent: string): McpClientId | undefined { + const alias = agent.trim().toLowerCase(); + // An object literal inherits `__proto__` and `constructor`, so looking either + // one up returns something truthy. Without this guard those two names read as + // a resolved agent and crash later instead of being rejected as unknown. + return Object.prototype.hasOwnProperty.call(CLIENT_ALIASES, alias) + ? CLIENT_ALIASES[alias] + : undefined; +} + +async function pathExists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +/** Agents that look installed on this machine, in registry order. */ +export async function detectMcpClients( + ctx: McpContext +): Promise { + const detected = await Promise.all( + ALL_MCP_CLIENT_IDS.map(async (id) => { + const found = await Promise.all( + MCP_CLIENTS[id].detectPaths(ctx).map(pathExists) + ); + return found.some(Boolean) ? id : undefined; + }) + ); + return detected.filter((id): id is McpClientId => id !== undefined); +} diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts new file mode 100644 index 0000000000..7f66ab035c --- /dev/null +++ b/src/utils/mcp-install.ts @@ -0,0 +1,495 @@ +/** + * Writes the Firecrawl MCP server into an agent's config, and optionally the + * rule that tells that agent to reach for Firecrawl on web work. + * + * Agent configs belong to the user, not to us, so edits are surgical: JSON is + * patched through a JSONC-aware editor that keeps comments and formatting + * intact (several agents ship commented settings, which plain `JSON.parse` + * rejects outright), TOML tables are replaced by AST source range, and shared + * rule files get a marker-fenced section rather than a rewrite. + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser'; +import { getStaticTOMLValue, parseTOML, type AST } from 'toml-eslint-parser'; +import { isMap, isScalar, parseDocument } from 'yaml'; +import { + MCP_CLIENTS, + MCP_SERVER_NAME, + RULE_MARKER, + type McpAuthMode, + type McpClient, + type McpClientId, + type McpContext, + type McpRuleSpec, + type McpTargetId, +} from './mcp-clients'; + +export type McpStatus = 'configured' | 'reconfigured' | 'failed'; +export type RuleStatus = + | 'installed' + | 'updated' + | 'skipped' + | 'unsupported' + | 'failed'; + +export interface McpClientResult { + id: McpTargetId; + name: string; + mcpStatus: McpStatus; + /** Config path on success, error message on failure. */ + mcpDetail: string; + /** How this agent ended up authenticating, after any keyless fallback. */ + auth: McpAuthMode; + ruleStatus: RuleStatus; + /** Rule path when one was written, error message on failure, else empty. */ + ruleDetail: string; +} + +function isEnoent(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'; +} + +async function readIfExists(filePath: string): Promise { + try { + return await fs.readFile(filePath, 'utf8'); + } catch (error) { + if (isEnoent(error)) return undefined; + throw error; + } +} + +async function writeFileEnsuringDir( + filePath: string, + content: string, + /** Applied by the OS only when the file is created, never to an existing one. */ + createMode?: number +): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, { encoding: 'utf8', mode: createMode }); +} + +/** + * Insert or replace `serversKey.serverName` without disturbing the rest of the + * file. Throws when the existing file is not parseable, so a malformed config + * is reported rather than overwritten. + */ +export async function writeJsonServerEntry( + filePath: string, + serversKey: string, + serverName: string, + entry: Record +): Promise<{ status: 'configured' | 'reconfigured' }> { + const stored = await readIfExists(filePath); + // A byte order mark is reported as a parse error even though the document is + // valid, and editors on Windows write one routinely. Keep it off the parse + // and put it back on write. + const bom = stored?.startsWith('\uFEFF') ? '\uFEFF' : ''; + const raw = bom ? stored!.slice(1) : stored; + + if (raw === undefined || raw.trim() === '') { + const fresh = { [serversKey]: { [serverName]: entry } }; + await writeFileEnsuringDir( + filePath, + `${bom}${JSON.stringify(fresh, null, 2)}\n` + ); + return { status: 'configured' }; + } + + const errors: ParseError[] = []; + const parsed = parse(raw, errors, { allowTrailingComma: true }); + if ( + errors.length > 0 || + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error(`could not parse existing config at ${filePath}`); + } + + const section = (parsed as Record)[serversKey]; + const sectionIsObject = + typeof section === 'object' && section !== null && !Array.isArray(section); + const alreadyExists = + sectionIsObject && serverName in (section as Record); + + // Patch the leaf when the servers map is usable; otherwise replace the whole + // key, which also covers it being missing or holding a non-object. + const edits = sectionIsObject + ? modify(raw, [serversKey, serverName], entry, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }) + : modify( + raw, + [serversKey], + { [serverName]: entry }, + { formattingOptions: { insertSpaces: true, tabSize: 2 } } + ); + + await writeFileEnsuringDir(filePath, `${bom}${applyEdits(raw, edits)}`); + return { status: alreadyExists ? 'reconfigured' : 'configured' }; +} + +/** + * Insert or replace `serversKey.serverName` in a YAML config. The document is + * edited as a tree rather than reserialised from plain objects, so comments, + * key order, and the user's formatting survive. Throws on a document that does + * not parse, matching how the JSON path treats a config it cannot read. + */ +export function upsertYamlServer( + content: string, + serversKey: string, + serverName: string, + entry: Record +): { content: string; alreadyExists: boolean } { + const doc = parseDocument(content); + if (doc.errors.length > 0) { + throw new Error(doc.errors[0].message); + } + + const alreadyExists = doc.hasIn([serversKey, serverName]); + const current = doc.getIn([serversKey], true); + // A key with nothing under it parses as a null scalar, and setting a path + // through that refuses to descend. It has to become a collection node: + // assigning a plain object leaves the same error one level down. An absent + // key needs none of this, since setIn creates the path itself. A scalar or + // sequence is already a value; replacing it would drop the user's data, so + // that fails instead of calling setIn (which throws a yaml-internal error). + if (isScalar(current) && current.value == null) { + const empty = current as { comment?: string | null }; + const section = doc.createNode({}); + // That comment belongs to the null value being replaced. A block map has + // no inline slot on its key, so it moves to the head of the section + // rather than being dropped with the node it was attached to. + if (empty?.comment) section.commentBefore = empty.comment; + doc.setIn([serversKey], section); + } else if (current != null && !isMap(current)) { + throw new Error(`Could not update ${serversKey}: expected a mapping.`); + } + doc.setIn([serversKey, serverName], entry); + + // Serialising the tree drops a byte order mark and normalises line endings. + // Both belong to the user's file, so they are restored on the way out. + const bom = content.startsWith('\uFEFF') ? '\uFEFF' : ''; + const eol = content.includes('\r\n') ? '\r\n' : '\n'; + const serialized = doc.toString().replace(/^\uFEFF/, ''); + return { + content: `${bom}${serialized.replace(/\r?\n/g, eol)}`, + alreadyExists, + }; +} + +function stripBom(content: string): { bom: string; raw: string } { + return content.startsWith('\uFEFF') + ? { bom: '\uFEFF', raw: content.slice(1) } + : { bom: '', raw: content }; +} + +function parseTomlDocument(raw: string): AST.TOMLProgram { + try { + return parseTOML(raw); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(reason); + } +} + +function isFirecrawlTable(node: AST.TOMLTable, serverName: string): boolean { + return ( + node.resolvedKey[0] === 'mcp_servers' && + String(node.resolvedKey[1]) === serverName + ); +} + +function staticHasServer(value: unknown, serverName: string): boolean { + if (!value || typeof value !== 'object') return false; + const servers = (value as Record).mcp_servers; + return ( + !!servers && + typeof servers === 'object' && + Object.prototype.hasOwnProperty.call(servers, serverName) + ); +} + +/** True when a TOML document already defines `mcp_servers.`. */ +export function tomlHasServer(content: string, serverName: string): boolean { + const { raw } = stripBom(content); + if (raw.trim() === '') return false; + try { + return staticHasServer(getStaticTOMLValue(parseTOML(raw)), serverName); + } catch { + return false; + } +} + +/** + * Insert or replace the `[mcp_servers.]` table. Matching uses the TOML + * AST `resolvedKey`, so quoted, spaced, and BOM-prefixed headers are the same + * table. Sub-tables of that server are consumed too, so a leftover + * `[mcp_servers.firecrawl.env]` from an earlier stdio setup cannot collide + * with the URL we write. + * + * Values are emitted as TOML strings; the entries we build are flat by design. + */ +export function upsertTomlServer( + content: string, + serverName: string, + entry: Record +): { content: string; alreadyExists: boolean } { + const { bom, raw } = stripBom(content); + const eol = raw.includes('\r\n') ? '\r\n' : '\n'; + const block = [ + `[mcp_servers.${serverName}]`, + ...Object.entries(entry).map( + ([key, value]) => `${key} = ${JSON.stringify(value)}` + ), + ].join(eol); + + const finish = (next: string, alreadyExists: boolean) => { + parseTomlDocument(next); + return { content: `${bom}${next}`, alreadyExists }; + }; + + if (raw.trim() === '') { + return finish(`${block}${eol}`, false); + } + + const ast = parseTomlDocument(raw); + const tables = ast.body[0].body.filter( + (node): node is AST.TOMLTable => + node.type === 'TOMLTable' && isFirecrawlTable(node, serverName) + ); + + if (tables.length === 0) { + if (staticHasServer(getStaticTOMLValue(ast), serverName)) { + throw new Error( + `Firecrawl is defined inline under mcp_servers; convert it to a [mcp_servers.${serverName}] table first` + ); + } + const trimmed = raw.replace(/(?:\r?\n)+$/, ''); + return finish(`${trimmed}${eol}${eol}${block}${eol}`, false); + } + + const ordered = [...tables].sort( + (left, right) => left.range[0] - right.range[0] + ); + const insertAt = ordered[0].range[0]; + let next = raw; + for (const table of [...ordered].reverse()) { + // The block goes in without a trailing newline, so whatever follows the + // first table has to keep supplying one. Taking it here would run the last + // value straight into the next line: `url = "..."[mcp_servers.other]`. + const end = + table === ordered[0] + ? table.range[1] + : tableRangeEnd(next, table.range[1]); + next = `${next.slice(0, table.range[0])}${next.slice(end)}`; + } + const replaced = `${next.slice(0, insertAt)}${block}${next.slice(insertAt)}`; + return finish(replaced.endsWith('\n') ? replaced : `${replaced}${eol}`, true); +} + +/** Include the table's terminating newline so a hole is not left behind. */ +function tableRangeEnd(raw: string, end: number): number { + if (raw.startsWith('\r\n', end)) return end + 2; + if (raw[end] === '\n') return end + 1; + return end; +} + +/** Rewrite a rule file we own outright. */ +export async function writeRuleFile( + filePath: string, + content: string +): Promise<'installed' | 'updated'> { + const existed = (await readIfExists(filePath)) !== undefined; + await writeFileEnsuringDir(filePath, content); + return existed ? 'updated' : 'installed'; +} + +/** + * Add or refresh a marker-fenced section inside a file the user also writes to, + * such as AGENTS.md. Everything outside the markers is left alone. + */ +export async function appendRuleSection( + filePath: string, + content: string +): Promise<'installed' | 'updated'> { + const existing = (await readIfExists(filePath)) ?? ''; + // The file belongs to the user, so the section adopts its line endings + // instead of mixing LF into a CRLF document. + const eol = existing.includes('\r\n') ? '\r\n' : '\n'; + const section = `${RULE_MARKER}${eol}${content.replace(/\r?\n/g, eol)}${RULE_MARKER}`; + + // The last two markers, not the first two. A file carrying an odd marker, + // from a half-written run or a hand edit, would otherwise pair that stray + // one with our opening marker and delete everything the user wrote between + // them. Our own section is always the final pair. + const close = existing.lastIndexOf(RULE_MARKER); + const open = close > 0 ? existing.lastIndexOf(RULE_MARKER, close - 1) : -1; + const opensWithNewline = /^\r?\n/.test( + existing.slice(open + RULE_MARKER.length, open + RULE_MARKER.length + 2) + ); + + if (open !== -1 && opensWithNewline) { + await writeFileEnsuringDir( + filePath, + `${existing.slice(0, open)}${section}${existing.slice(close + RULE_MARKER.length)}` + ); + return 'updated'; + } + + const separator = + existing.length === 0 ? '' : existing.endsWith('\n') ? eol : `${eol}${eol}`; + await writeFileEnsuringDir( + filePath, + `${existing}${separator}${section}${eol}` + ); + return 'installed'; +} + +async function writeMcpEntry( + client: McpClient, + ctx: McpContext +): Promise<{ status: 'configured' | 'reconfigured'; configPath: string }> { + const configPath = client.globalConfigPath(ctx); + // Some agents will not start the sign-in flow from a URL alone. + const entry = + ctx.auth === 'oauth' && client.oauth?.entry + ? { ...client.buildEntry(ctx), ...client.oauth.entry } + : client.buildEntry(ctx); + + if (client.format === 'yaml') { + const existing = (await readIfExists(configPath)) ?? ''; + let patched: { content: string; alreadyExists: boolean }; + try { + patched = upsertYamlServer( + existing, + client.serversKey, + MCP_SERVER_NAME, + entry + ); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `could not parse existing config at ${configPath}: ${reason}` + ); + } + await writeFileEnsuringDir(configPath, patched.content, client.createMode); + return { + status: patched.alreadyExists ? 'reconfigured' : 'configured', + configPath, + }; + } + + if (client.format === 'toml') { + const existing = (await readIfExists(configPath)) ?? ''; + const stringEntry: Record = {}; + for (const [key, value] of Object.entries(entry)) { + if (typeof value === 'string') stringEntry[key] = value; + } + let patched: { content: string; alreadyExists: boolean }; + try { + patched = upsertTomlServer(existing, MCP_SERVER_NAME, stringEntry); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `could not parse existing config at ${configPath}: ${reason}` + ); + } + const { content, alreadyExists } = patched; + await writeFileEnsuringDir(configPath, content); + return { + status: alreadyExists ? 'reconfigured' : 'configured', + configPath, + }; + } + + const { status } = await writeJsonServerEntry( + configPath, + client.serversKey, + MCP_SERVER_NAME, + entry + ); + return { status, configPath }; +} + +export async function writeConfiguredRule( + rule: McpRuleSpec, + rulePath: string +): Promise<{ status: 'installed' | 'updated' | 'unsupported'; path: string }> { + switch (rule.kind) { + case 'manual': + return { status: 'unsupported', path: rule.nextStep }; + case 'file': + return { + status: await writeRuleFile(rulePath, rule.content), + path: rulePath, + }; + case 'append': + return { + status: await appendRuleSection(rulePath, rule.content), + path: rulePath, + }; + default: { + const unreachable: never = rule; + return unreachable; + } + } +} + +async function writeRule( + client: McpClient, + ctx: McpContext +): Promise<{ status: 'installed' | 'updated' | 'unsupported'; path: string }> { + const rule = client.rule; + if (!rule) return { status: 'unsupported', path: '' }; + const rulePath = rule.kind === 'manual' ? '' : rule.globalPath(ctx); + return writeConfiguredRule(rule, rulePath); +} + +/** + * Configure one agent. The MCP entry and the rule are written independently so + * a rule failure never costs the user a working MCP server. + */ +export async function setupMcpClient( + id: McpClientId, + options: { rules: boolean; ctx: McpContext } +): Promise { + const client = MCP_CLIENTS[id]; + const ctx = options.ctx; + + const result: McpClientResult = { + id, + name: client.name, + mcpStatus: 'failed', + mcpDetail: '', + auth: ctx.auth, + ruleStatus: 'skipped', + ruleDetail: '', + }; + + try { + const { status, configPath } = await writeMcpEntry(client, ctx); + result.mcpStatus = status; + result.mcpDetail = configPath; + } catch (error) { + result.mcpDetail = error instanceof Error ? error.message : String(error); + } + + // The rule tells an agent to prefer Firecrawl tools. Writing one for an agent + // whose server entry failed would point it at tools it does not have, so the + // dependency runs this way only: a failed rule still leaves MCP working. + if (!options.rules || result.mcpStatus === 'failed') return result; + + try { + const { status, path: rulePath } = await writeRule(client, ctx); + result.ruleStatus = status; + result.ruleDetail = rulePath; + } catch (error) { + result.ruleStatus = 'failed'; + result.ruleDetail = error instanceof Error ? error.message : String(error); + } + + return result; +} diff --git a/src/utils/run-client-command.ts b/src/utils/run-client-command.ts new file mode 100644 index 0000000000..63f4ce140b --- /dev/null +++ b/src/utils/run-client-command.ts @@ -0,0 +1,94 @@ +/** + * Cross-platform spawn for agent CLIs. Windows npm shims are `.cmd` files, + * which Node's `execFileSync` cannot launch; those go through cmd.exe with + * escaped argv. Everywhere else this is a direct exec. + */ + +import { execFileSync } from 'child_process'; +import { existsSync } from 'fs'; +import path from 'path'; + +const CMD_META_CHARS = /([()%!^"<>&|])/g; + +function rejectCommandControlCharacters(value: string, label: string): void { + if (/[\0\r\n]/.test(value)) { + throw new Error(`${label} contains an unsupported control character.`); + } +} + +/** Quote one argv value for cmd.exe using the same two-layer escaping model as + * established Windows spawn libraries: first the C runtime, then cmd.exe. */ +function escapeCmdArg(arg: string): string { + rejectCommandControlCharacters(arg, 'Command argument'); + const quoted = `"${arg + .replace(/(\\*)"/g, '$1$1\\"') + .replace(/(\\*)$/, '$1$1')}"`; + return quoted.replace(CMD_META_CHARS, '^$1'); +} + +function windowsPathExtensions(env: NodeJS.ProcessEnv): string[] { + const configured = env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD'; + return configured + .split(';') + .map((extension) => extension.trim()) + .filter(Boolean); +} + +/** Resolve the actual Windows launcher instead of assuming every tool is a + * `.cmd` shim. Native `.exe` clients must bypass cmd.exe entirely. */ +function resolveWindowsCommand( + command: string, + env: NodeJS.ProcessEnv +): string { + rejectCommandControlCharacters(command, 'Command'); + const hasPath = /[\\/]/.test(command); + const hasExtension = path.extname(command) !== ''; + const candidates = hasExtension + ? [command] + : windowsPathExtensions(env).map((extension) => `${command}${extension}`); + const pathEntries = hasPath + ? [''] + : (env.PATH ?? env.Path ?? env.path ?? '') + .split(path.delimiter) + .map((entry) => entry.replace(/^"|"$/g, '')) + .filter(Boolean); + + for (const directory of pathEntries) { + for (const candidate of candidates) { + const resolved = directory ? path.join(directory, candidate) : candidate; + if (existsSync(resolved)) return resolved; + } + } + + // Let CreateProcess perform its normal resolution for native executables. + // Crucially, do not silently rewrite an unknown command to `.cmd`. + return command; +} + +export function runClientCommand( + command: string, + args: string[], + options: Parameters[2] +): ReturnType { + rejectCommandControlCharacters(command, 'Command'); + for (const arg of args) + rejectCommandControlCharacters(arg, 'Command argument'); + + if (process.platform !== 'win32') { + return execFileSync(command, args, options); + } + + const env = options?.env ?? process.env; + const resolved = resolveWindowsCommand(command, env); + if (!/\.(?:cmd|bat)$/i.test(resolved)) { + return execFileSync(resolved, args, options); + } + + const line = [escapeCmdArg(resolved), ...args.map(escapeCmdArg)].join(' '); + const comspec = env.ComSpec ?? env.COMSPEC ?? 'cmd.exe'; + const windowsOptions = { + ...options, + windowsVerbatimArguments: true, + } as Parameters[2]; + return execFileSync(comspec, ['/d', '/s', '/c', `"${line}"`], windowsOptions); +}