From f3129449e00a6298e56eca319a09e516ef7f33c3 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Tue, 11 Aug 2026 17:24:01 -0700 Subject: [PATCH 01/35] feat(cli): configure MCP natively across coding agents Replace the subprocess installer in `setup mcp` with a built-in one that detects installed agents, pre-selects them in a picker, and offers to install rules telling those agents to prefer Firecrawl for web search and scraping. Covers Claude Code, Cursor, VS Code, Codex, OpenCode, Windsurf, Zed, Hermes Agent, and OpenClaw through per-agent flags, `--project` for project scope, and `--rules` / `--no-rules` for scripted runs. `-y` stays MCP-only. The two launchers were previously reachable only by flag, so a plain `setup mcp` never offered them; they now sit in the picker alongside the editors, and because a launcher shells out to a CLI, a missing binary is reported against that one agent instead of ending the run. Credential handling is unchanged in principle and stricter in reach: an API key is never written as a literal. Knowing which agents were selected means each one receives a reference to FIRECRAWL_API_KEY in the syntax it expands, so the setup no longer has to refuse a run that omits --agent. Agents with no verified syntax fall back to the keyless endpoint and say so rather than persisting a secret. Config edits are surgical. JSON is patched through a JSONC-aware editor so commented settings files parse at all and keep their comments, and TOML tables are replaced along with any stale sub-tables left by a previous stdio entry. Reruns are byte-identical. Also gives every setup test a throwaway HOME and resets spawn mocks between tests, since MCP setup now writes real config files and would otherwise rewrite the developer's own agent settings; and teaches doctor about the `servers` and `context_servers` keys so those registrations are recognized. --- README.md | 30 +- package.json | 1 + pnpm-lock.yaml | 8 + src/__tests__/commands/setup.test.ts | 653 ++++++++++++++---------- src/__tests__/utils/mcp-install.test.ts | 373 ++++++++++++++ src/commands/setup.ts | 394 +++++++++----- src/index.ts | 28 +- src/utils/agents.ts | 12 +- src/utils/mcp-clients.ts | 446 ++++++++++++++++ src/utils/mcp-install.ts | 339 ++++++++++++ 10 files changed, 1873 insertions(+), 411 deletions(-) create mode 100644 src/__tests__/utils/mcp-install.test.ts create mode 100644 src/utils/mcp-clients.ts create mode 100644 src/utils/mcp-install.ts diff --git a/README.md b/README.md index 6576734427..f50d035429 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,40 @@ 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, pre-selects them in a picker, 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, Windsurf, Zed, Hermes Agent, and OpenClaw. + +Pass agent flags to skip the picker, `-y` to configure every detected agent +(MCP only), or `--project` to write to the current project instead of your +global agent settings: + +```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 +firecrawl setup mcp --project --cursor # write project config +``` + +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. + +Not every agent supports project-level MCP configuration. Those agents always +receive the global configuration. + To make Firecrawl the default web provider for supported AI agents: ```bash diff --git a/package.json b/package.json index 097a4ee0e5..ef0c8f1ed6 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@inquirer/prompts": "^8.2.1", "commander": "^14.0.2", "firecrawl": "4.24.0", + "jsonc-parser": "3.3.1", "yaml": "^2.9.0", "zod-to-json-schema": "3.24.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5126333bf..b056306794 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: firecrawl: specifier: 4.24.0 version: 4.24.0 + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -752,6 +755,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'} @@ -1631,6 +1637,8 @@ snapshots: isexe@2.0.0: {} + jsonc-parser@3.3.1: {} + lilconfig@3.1.3: {} lint-staged@15.5.2: diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 5dad063bc3..23db1c016b 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -22,6 +22,20 @@ import { import { ALL_SKILL_REPOS } from '../../commands/skills-install'; import { configureWebDefaults } from '../../utils/web-defaults'; import { getApiKey } from '../../utils/config'; +import { MCP_CLIENTS, 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(), @@ -39,16 +53,26 @@ vi.mock('../../utils/config', () => ({ describe('handleSetupCommand', () => { let originalHome: string | undefined; let originalApiKey: string | undefined; + let sandboxHome: 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; }); afterEach(() => { + rmSync(sandboxHome, { recursive: true, force: true }); if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; if (originalApiKey === undefined) delete process.env.FIRECRAWL_API_KEY; @@ -123,6 +147,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,21 +159,11 @@ 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.objectContaining({ stdio: 'inherit' }) - ); + expect( + JSON.parse( + readFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), 'utf-8') + ).mcpServers.firecrawl + ).toEqual({ url: MCP_URL }); }); it('requires a subcommand for bare setup in non-interactive mode', async () => { const originalIsTty = process.stdin.isTTY; @@ -196,159 +211,206 @@ 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'; - await handleSetupCommand('mcp', { - agent, - global: true, - yes: true, - }); + try { + await handleSetupCommand('mcp', { agent, 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(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'; - await handleSetupCommand('mcp', { - agent: 'codex', - global: true, - yes: true, - }); + try { + await handleSetupCommand('mcp', { + agent: 'codex', + 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' }) - ); - expect(vi.mocked(execFileSync).mock.calls.flat(2).join(' ')).not.toContain( - '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('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', { hermes: true, yes: true } as never); + + expect( + readFileSync(path.join(sandboxHome, '.hermes', 'config.yaml'), 'utf-8') + ).toContain('firecrawl:'); + }); + + it('detects an installed launcher so the picker can pre-select it', async () => { + mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + + const { detectMcpLaunchers } = await import('../../utils/mcp-clients'); + expect( + detectMcpLaunchers({ + home: sandboxHome, + cwd: process.cwd(), + platform: process.platform, + env: { PATH: '' }, + auth: 'keyless', + }) + ).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'); + }); await handleSetupCommand('mcp', { - agent: 'claude-code', - global: true, + cursor: true, + openclaw: true, yes: true, - }); + } as never); - 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( + JSON.parse( + readFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), 'utf-8') + ).mcpServers.firecrawl.url + ).toBe(MCP_URL); }); it('rejects a stored key before writing Hermes MCP config', async () => { @@ -475,6 +537,10 @@ describe('handleSetupCommand', () => { }); 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'; @@ -485,22 +551,23 @@ describe('handleSetupCommand', () => { yes: true, }); - 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}'), - ]) + 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(calls.flat(2).join(' ')).not.toContain('Bearer fc-test-key'); + 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}'); @@ -509,28 +576,35 @@ describe('handleSetupCommand', () => { } }); - it('rejects authenticated --agent all project setup before changing any client', async () => { + it('keeps an environment-backed --agent all project setup free of literals', async () => { const home = mkdtempSync( - path.join(os.tmpdir(), 'firecrawl-all-project-preflight-') + path.join(os.tmpdir(), 'firecrawl-all-project-env-') ); + mkdirSync(path.join(home, '.cursor'), { recursive: true }); process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-proj-cwd-')); + const originalCwd = process.cwd(); + process.chdir(cwd); 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', + project: true, + yes: true, + }); - expect(execFileSync).not.toHaveBeenCalled(); - expect(execSync).not.toHaveBeenCalled(); - expect(existsSync(path.join(home, '.hermes', 'config.yaml'))).toBe(false); + const config = readFileSync( + path.join(cwd, '.cursor', 'mcp.json'), + 'utf-8' + ); + expect(JSON.parse(config).mcpServers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', + }); + expect(config).not.toContain('fc-test-key'); } finally { + process.chdir(originalCwd); + rmSync(cwd, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); } }); @@ -549,32 +623,45 @@ describe('handleSetupCommand', () => { 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 +669,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,27 +726,36 @@ 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); + 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 }); + } }); + // --- Scope: project and global are mutually exclusive --- it('rejects conflicting MCP scope flags', async () => { @@ -666,70 +771,78 @@ describe('handleSetupCommand', () => { it('keeps project scope for an environment-backed credential', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-env-')); + const originalCwd = process.cwd(); + process.chdir(cwd); - await handleSetupCommand('mcp', { - agent: 'cursor', - project: true, - yes: true, - }); + try { + 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'); + const config = readFileSync( + path.join(cwd, '.cursor', 'mcp.json'), + 'utf-8' + ); + expect(JSON.parse(config).mcpServers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', + }); + expect(config).not.toContain('fc-test-key'); + } finally { + process.chdir(originalCwd); + rmSync(cwd, { recursive: true, force: true }); + } }); - it('does not force global MCP scope in the default bundle when --project is set', async () => { + it('writes project scope rather than global when --project is set', async () => { vi.mocked(getApiKey).mockReturnValue(undefined); + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-home-')); + process.env.HOME = home; + const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-cwd-')); + const originalCwd = process.cwd(); + process.chdir(cwd); - 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, - }); + try { + await handleSetupCommand('mcp', { + agent: 'cursor', + project: true, + 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(existsSync(path.join(cwd, '.cursor', 'mcp.json'))).toBe(true); + expect(existsSync(path.join(home, '.cursor', 'mcp.json'))).toBe(false); + } finally { + process.chdir(originalCwd); + rmSync(cwd, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } }); - it('does not force global for the keyless (unauthenticated) setup', async () => { + it('defaults to global scope without --project', 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 +861,7 @@ describe('handleSetupCommand', () => { try { await handleSetupCommand('mcp', { - agent: 'claude-code', + agent: 'openclaw', global: true, yes: true, }); @@ -761,11 +874,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 +892,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 +912,7 @@ describe('handleSetupCommand', () => { try { await handleSetupCommand('mcp', { - agent: 'codex', + agent: 'openclaw', global: true, yes: true, }); @@ -808,8 +921,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 +937,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..bb4c1e064c --- /dev/null +++ b/src/__tests__/utils/mcp-install.test.ts @@ -0,0 +1,373 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import os from 'os'; +import path from 'path'; +import { + detectMcpClients, + resolveMcpClientId, + type McpContext, +} from '../../utils/mcp-clients'; +import { + appendRuleSection, + setupMcpClient, + upsertTomlServer, + 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, + [ + '// Zed settings', + '{', + ' "theme": "One Dark",', + ' // keep me', + ' "buffer_font_size": 15,', + ' "context_servers": { "other": { "url": "https://example.com" } }', + '}', + '', + ].join('\n') + ); + + await writeJsonServerEntry(file, 'context_servers', 'fc', { + url: MCP_URL, + }); + + const result = read(file); + expect(result).toContain('// Zed settings'); + expect(result).toContain('// keep me'); + expect(result).toContain('"theme": "One Dark"'); + expect(result).toContain('"other"'); + 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('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('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); + }); + }); + + 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); + }); + }); + + describe('setupMcpClient', () => { + it('writes the keyless URL with no credentials', async () => { + const result = await setupMcpClient('cursor', { + scope: 'global', + 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', { + scope: 'global', + 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('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, { + scope: 'global', + 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', { + scope: 'global', + 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('falls back to keyless for agents that cannot expand variables', async () => { + for (const id of ['zed', 'windsurf'] as const) { + const result = await setupMcpClient(id, { + scope: 'global', + rules: false, + ctx: { ...ctx, auth: 'env' }, + }); + + expect(result.auth).toBe('keyless'); + expect(read(result.mcpDetail)).not.toContain('Authorization'); + } + }); + + it('honours CLAUDE_CONFIG_DIR', async () => { + const configDir = path.join(root, 'claude-config'); + + const result = await setupMcpClient('claude', { + scope: 'global', + 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('falls back to global config for agents without project support', async () => { + const result = await setupMcpClient('windsurf', { + scope: 'project', + rules: true, + ctx, + }); + + // MCP is global-only for Windsurf; the rule still lands in the project. + expect(result.mcpDetail).toBe( + path.join(ctx.home, '.codeium', 'windsurf', 'mcp_config.json') + ); + expect(result.ruleDetail).toBe( + path.join(ctx.cwd, '.windsurf', 'rules', 'firecrawl.md') + ); + }); + + it('marks rules unsupported for agents without a rules mechanism', async () => { + const result = await setupMcpClient('zed', { + scope: 'global', + rules: true, + ctx, + }); + + expect(result.mcpStatus).toBe('configured'); + expect(result.ruleStatus).toBe('unsupported'); + }); + + it('still configures MCP when the rule write fails', async () => { + // A file where the rules directory needs to be blocks the rule write. + const rulesPath = path.join(ctx.home, '.cursor', 'rules'); + mkdirSync(path.dirname(rulesPath), { recursive: true }); + writeFileSync(rulesPath, 'not a directory'); + + const result = await setupMcpClient('cursor', { + scope: 'global', + rules: true, + ctx, + }); + + expect(result.mcpStatus).toBe('configured'); + expect(result.ruleStatus).toBe('failed'); + }); + + 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', { + scope: 'global', + 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']); + }); + }); + + 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(); + }); + }); +}); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 1ed3a4a0fd..25f23f9f60 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -28,13 +28,28 @@ import { WEB_AGENTS, type WebAgent, } from '../utils/web-defaults'; +import { + ALL_MCP_LAUNCHER_IDS, + ALL_MCP_TARGET_IDS, + detectMcpClients, + detectMcpLaunchers, + isMcpLauncherId, + mcpTargetName, + resolveMcpClientId, + type McpAuthMode, + type McpContext, + type McpLauncherId, + type McpScope, + type McpTargetId, +} from '../utils/mcp-clients'; +import { setupMcpClient, type McpClientResult } from '../utils/mcp-install'; export type SetupSubcommand = 'skills' | 'workflows' | 'mcp' | 'defaults'; type SetupIntegration = SetupSubcommand; type ResolvedMcpAgent = - | { kind: 'add-mcp'; agent?: string; all?: boolean } + | { kind: 'clients'; ids?: McpTargetId[] } | { kind: 'hermes' } | { kind: 'openclaw' } | { kind: 'all-launchers' }; @@ -53,20 +68,18 @@ export interface SetupOptions { quiet?: boolean; /** Configure the anonymous hosted MCP path even when a stored key exists. */ keyless?: 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', @@ -236,7 +249,7 @@ 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) { @@ -245,28 +258,20 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { 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' }; case 'openclaw': return { kind: 'openclaw' }; - default: - return { kind: 'add-mcp', agent }; + default: { + const id = resolveMcpClientId(normalized); + if (!id) { + throw new Error( + `Unknown agent "${agent}" for setup mcp. Use one of: ${ALL_MCP_TARGET_IDS.join(', ')}, all.` + ); + } + return { kind: 'clients', ids: [id] }; + } } } @@ -544,148 +549,263 @@ export async function installMcp( 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." - ); - } + if (resolvedAgent.kind === 'hermes') { await installHermesMcp(runtimeEnv, options.keyless); return; } - assertSubprocessSafeCredential(apiKey, runtimeEnv); if (resolvedAgent.kind === 'openclaw') { + // Hands the credential to a subprocess, so a stored key is not usable. + assertSubprocessSafeCredential(apiKey, runtimeEnv); await installOpenClawMcp(runtimeEnv, options.keyless); return; } if (resolvedAgent.kind === 'all-launchers') { - await installAllMcpLaunchers(options, runtimeEnv); + // Fails closed before touching anything: this path reaches launchers that + // hand the credential to a subprocess. + assertSubprocessSafeCredential(apiKey, runtimeEnv); + await installMcpClients({ ...options, yes: true }, runtimeEnv, undefined, { + includeAllLaunchers: true, + }); return; } - await installAddMcp(options, resolvedAgent, runtimeEnv); + await installMcpClients(options, runtimeEnv, resolvedAgent.ids); } -async function installAllMcpLaunchers( - options: SetupOptions, +/** 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; + } + if (target === ctx.home) return '~'; + return target.startsWith(ctx.home + path.sep) + ? path.join('~', path.relative(ctx.home, target)) + : target; +} + +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, + choices: ALL_MCP_TARGET_IDS.map((id) => ({ + name: mcpTargetName(id), + value: id, + checked: detected.includes(id), + })), + }); +} + +/** + * 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 -): Promise { - for (const agent of ADD_MCP_LAUNCH_AGENTS) { - await installAddMcp( - { ...options, yes: true }, - { kind: 'add-mcp', agent }, - runtimeEnv - ); +): Promise { + const keyless = ctx.auth !== 'env'; + const result: McpClientResult = { + id, + name: mcpTargetName(id), + mcpStatus: 'failed', + mcpDetail: '', + auth: keyless ? 'keyless' : 'env', + ruleStatus: 'unsupported', + ruleDetail: '', + }; + + try { + if (id === 'hermes') { + await installHermesMcp(runtimeEnv, keyless, true); + result.mcpDetail = path.join(ctx.home, '.hermes', 'config.yaml'); + } else { + await installOpenClawMcp(runtimeEnv, keyless, true); + result.mcpDetail = 'via the openclaw CLI'; + } + result.mcpStatus = 'configured'; + } catch (error) { + result.mcpDetail = error instanceof Error ? error.message : String(error); } - await installHermesMcp(runtimeEnv, options.keyless); - await installOpenClawMcp(runtimeEnv, options.keyless); + 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 installAddMcp( +async function installMcpClients( options: SetupOptions, - resolvedAgent: Extract, - runtimeEnv: NodeJS.ProcessEnv + runtimeEnv: NodeJS.ProcessEnv, + explicitIds?: McpTargetId[], + { includeAllLaunchers = false } = {} ): 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; + // 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 = 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, + }; + const scope: McpScope = options.project ? 'project' : 'global'; + // Prompts only make sense when someone is there to answer them. + const nonInteractive = Boolean(options.yes) || !process.stdin.isTTY; + + let selected = explicitIds ?? options.clients; + if (!selected || selected.length === 0) { + const detected: McpTargetId[] = [ + ...(await detectMcpClients(ctx)), + ...detectMcpLaunchers(ctx), + ]; + if (nonInteractive) { + if (detected.length === 0 && !includeAllLaunchers) { + throw new Error( + 'No coding agents detected. Pass an agent flag such as --claude or --cursor.' + ); + } + selected = detected; + } else { + selected = await pickMcpClients(detected); + if (selected.length === 0) { + console.log('No agents selected. Nothing changed.'); + return; + } + } } - const headers = firecrawlMcpHeaders(resolvedAgent.agent, apiKey, runtimeEnv); - const useGlobal = !options.project && Boolean(options.global); - - const args = [ - '-y', - ADD_MCP_PACKAGE, - mcpUrl, - '--name', - 'firecrawl', - '--transport', - 'http', - ]; - - if (headers?.Authorization) { - args.push('--header', `Authorization: ${headers.Authorization}`); + // `--agent all` reaches every launch 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, + ]; } - if (useGlobal) { - args.push('--global'); - } + // `-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()); - if (resolvedAgent.agent) { - args.push('--agent', resolvedAgent.agent); - } else if (resolvedAgent.all) { - args.push('--all'); + const results: McpClientResult[] = []; + for (const id of selected) { + results.push( + isMcpLauncherId(id) + ? await setupMcpLauncher(id, ctx, runtimeEnv) + : await setupMcpClient(id, { scope, rules, ctx }) + ); } - if (options.yes) { - args.push('--yes'); - } + reportMcpResults(results, ctx, options, Boolean(apiKey)); +} - if (!options.quiet) { - console.log('Configuring Firecrawl MCP...\n'); +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': + return ` Rules ${dim}not supported by this agent${reset}`; + case 'failed': + return ` ${red}Rules failed${reset} ${result.ruleDetail}`; } +} - 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}`); - } - } catch { - throw new Error('Failed to configure Firecrawl MCP.'); - } +/** + * 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 (!hasApiKey) { + return [ + 'Running keyless (search, scrape, parse). Run "firecrawl login" and rerun to unlock the full tool surface.', + ]; + } + + 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.`, + ]; + } + + const keyless = succeeded.filter((result) => result.auth === 'keyless'); + if (keyless.length === 0) return []; + return [ + `${keyless.map((result) => result.name).join(' and ')} cannot expand environment variables in MCP config, so ${keyless.length > 1 ? 'they were' : 'it was'} configured keyless.`, + ]; } -function installCodexMcpFromEnvironment( +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}` + ); + } + 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}` ); - if (options.quiet) { - console.log(` ${green}✓${reset} Firecrawl MCP configured for codex`); - } - } catch { - throw new Error('Failed to configure Firecrawl MCP for Codex.'); + 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}`); + } + + if (succeeded.length === 0) { + throw new Error('Failed to configure Firecrawl MCP.'); } } @@ -710,7 +830,9 @@ function firecrawlMcpConfig( export async function installHermesMcp( runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false + keyless = false, + /** Suppress standalone logging when a caller renders its own summary. */ + quiet = false ): Promise { const config = firecrawlMcpConfig('hermes', runtimeEnv, keyless); const configPath = path.join(os.homedir(), '.hermes', 'config.yaml'); @@ -736,18 +858,20 @@ export async function installHermesMcp( if (process.platform !== 'win32') { chmodSync(configPath, 0o600); } - console.log(`Hermes Agent MCP configured at ${configPath}.`); + if (!quiet) 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 ): Promise { const config = { ...firecrawlMcpConfig('openclaw', runtimeEnv, keyless), transport: 'streamable-http', }; - console.log('Configuring Firecrawl MCP for OpenClaw...\n'); + if (!quiet) console.log('Configuring Firecrawl MCP for OpenClaw...\n'); try { runClientCommand( diff --git a/src/index.ts b/src/index.ts index 87ecfee452..a07d9e602e 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)' @@ -2261,9 +2262,32 @@ program .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)`); +} + +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 --claude --cursor # skip the picker + $ firecrawl setup mcp --yes # every detected agent, MCP only + $ firecrawl setup mcp --yes --rules # every detected agent, with rules + $ firecrawl setup mcp --project --cursor # write project config +` ) .action(async (subcommand: SetupSubcommand, options) => { - await handleSetupCommand(subcommand, options); + 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..ea0d50d290 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -167,8 +167,14 @@ async function fileHasFirecrawlMcp(filePath: string): Promise { } /** - * Walk a parsed JSON config looking for an `mcpServers` (or `mcp.servers`) - * map that contains a `firecrawl` key. Exported for testing. + * Keys under which agents store their MCP server map: `mcpServers` for Claude + * Code, Cursor, and Windsurf, `servers` for VS Code, `context_servers` for Zed. + */ +const SERVER_MAP_KEYS = new Set(['mcpServers', 'servers', 'context_servers']); + +/** + * Walk a parsed JSON config looking for a server map (or `mcp.servers`) that + * contains a `firecrawl` key. Exported for testing. */ export function hasFirecrawlMcpEntry(value: unknown): boolean { if (!value || typeof value !== 'object') return false; @@ -176,7 +182,7 @@ export function hasFirecrawlMcpEntry(value: unknown): boolean { for (const key of Object.keys(obj)) { const child = obj[key]; - if (key === 'mcpServers' && child && typeof child === 'object') { + if (SERVER_MAP_KEYS.has(key) && child && typeof child === 'object') { if (Object.prototype.hasOwnProperty.call(child, 'firecrawl')) { return true; } diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts new file mode 100644 index 0000000000..7a30022ccc --- /dev/null +++ b/src/utils/mcp-clients.ts @@ -0,0 +1,446 @@ +/** + * 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. Agents without a verified syntax get the keyless endpoint, which + * still serves search, scrape, and parse under an anonymous rate limit. + */ + +import { existsSync, promises as fs } from 'fs'; +import path from 'path'; + +export const FIRECRAWL_MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; +export const MCP_SERVER_NAME = 'firecrawl'; +export const API_KEY_ENV_VAR = 'FIRECRAWL_API_KEY'; + +export type McpClientId = + | 'claude' + | 'cursor' + | 'vscode' + | 'codex' + | 'opencode' + | 'windsurf' + | 'zed'; + +export type McpScope = 'global' | 'project'; + +/** + * Agent launchers that own their MCP configuration rather than reading a file + * we write. They are offered alongside the editors but installed differently. + */ +export type McpLauncherId = 'hermes' | '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. + */ +export type McpAuthMode = 'env' | 'keyless'; + +export interface McpContext { + home: string; + cwd: string; + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + auth: McpAuthMode; +} + +export interface 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; + projectPath?: (ctx: McpContext) => string; +} + +export interface McpClient { + id: McpClientId; + name: string; + format: 'json' | 'toml'; + /** Key of the map holding MCP servers in this agent's config. */ + serversKey: string; + globalConfigPath: (ctx: McpContext) => string; + /** Absent when the agent only supports global MCP configuration. */ + projectConfigPath?: (ctx: McpContext) => string; + buildEntry: (ctx: McpContext) => Record; + /** + * True when this agent can authenticate without a literal key: either it + * expands an env reference in headers, or it resolves the variable natively. + */ + supportsEnvAuth: boolean; + /** 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 CURSOR_RULE = `--- +alwaysApply: true +--- + +${RULE_BODY}`; + +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. */ +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'); +} + +function zedUserDir(ctx: McpContext): string { + if (ctx.platform === 'win32') return appSupportDir(ctx, 'Zed'); + return path.join(ctx.home, '.config', 'zed'); +} + +/** 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, + projectConfigPath: (ctx) => path.join(ctx.cwd, '.mcp.json'), + buildEntry: (ctx) => + withEnvAuth( + ctx, + { type: 'http', url: FIRECRAWL_MCP_URL }, + ENV_HEADER.shell + ), + supportsEnvAuth: true, + rule: { + kind: 'file', + content: RULE_BODY, + globalPath: (ctx) => + path.join(claudeConfigDir(ctx), 'rules', 'firecrawl.md'), + projectPath: (ctx) => + path.join(ctx.cwd, '.claude', 'rules', 'firecrawl.md'), + }, + detectPaths: (ctx) => [claudeConfigDir(ctx)], + }, + cursor: { + id: 'cursor', + name: 'Cursor', + format: 'json', + serversKey: 'mcpServers', + globalConfigPath: (ctx) => path.join(ctx.home, '.cursor', 'mcp.json'), + projectConfigPath: (ctx) => path.join(ctx.cwd, '.cursor', 'mcp.json'), + buildEntry: (ctx) => + withEnvAuth(ctx, { url: FIRECRAWL_MCP_URL }, ENV_HEADER.editor), + supportsEnvAuth: true, + rule: { + kind: 'file', + content: CURSOR_RULE, + globalPath: (ctx) => + path.join(ctx.home, '.cursor', 'rules', 'firecrawl.mdc'), + projectPath: (ctx) => + path.join(ctx.cwd, '.cursor', 'rules', 'firecrawl.mdc'), + }, + 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'), + projectConfigPath: (ctx) => path.join(ctx.cwd, '.vscode', 'mcp.json'), + buildEntry: (ctx) => + withEnvAuth( + ctx, + { type: 'http', url: FIRECRAWL_MCP_URL }, + ENV_HEADER.editor + ), + supportsEnvAuth: true, + rule: { + kind: 'file', + content: VSCODE_RULE, + globalPath: (ctx) => + path.join(vscodeUserDir(ctx), 'prompts', 'firecrawl.instructions.md'), + projectPath: (ctx) => + path.join( + ctx.cwd, + '.github', + 'instructions', + 'firecrawl.instructions.md' + ), + }, + detectPaths: (ctx) => [vscodeUserDir(ctx)], + }, + codex: { + id: 'codex', + name: 'Codex', + format: 'toml', + serversKey: 'mcp_servers', + globalConfigPath: (ctx) => path.join(ctx.home, '.codex', 'config.toml'), + projectConfigPath: (ctx) => path.join(ctx.cwd, '.codex', '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: FIRECRAWL_MCP_URL, bearer_token_env_var: API_KEY_ENV_VAR } + : { url: FIRECRAWL_MCP_URL }, + supportsEnvAuth: true, + rule: { + kind: 'append', + content: RULE_BODY, + globalPath: (ctx) => path.join(ctx.home, '.codex', 'AGENTS.md'), + projectPath: (ctx) => path.join(ctx.cwd, 'AGENTS.md'), + }, + detectPaths: (ctx) => [path.join(ctx.home, '.codex')], + }, + opencode: { + id: 'opencode', + name: 'OpenCode', + format: 'json', + serversKey: 'mcp', + globalConfigPath: (ctx) => + path.join(ctx.home, '.config', 'opencode', 'opencode.json'), + projectConfigPath: (ctx) => path.join(ctx.cwd, 'opencode.json'), + buildEntry: (ctx) => + withEnvAuth( + ctx, + { type: 'remote', url: FIRECRAWL_MCP_URL, enabled: true }, + ENV_HEADER.brace + ), + supportsEnvAuth: true, + rule: { + kind: 'append', + content: RULE_BODY, + globalPath: (ctx) => + path.join(ctx.home, '.config', 'opencode', 'AGENTS.md'), + projectPath: (ctx) => path.join(ctx.cwd, 'AGENTS.md'), + }, + detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], + }, + windsurf: { + id: 'windsurf', + name: 'Windsurf', + format: 'json', + serversKey: 'mcpServers', + // Windsurf has no project-level MCP config; it always gets the global one. + globalConfigPath: (ctx) => + path.join(ctx.home, '.codeium', 'windsurf', 'mcp_config.json'), + buildEntry: () => ({ serverUrl: FIRECRAWL_MCP_URL }), + // No verified env-reference syntax, so this agent stays keyless. + supportsEnvAuth: false, + rule: { + kind: 'append', + content: RULE_BODY, + globalPath: (ctx) => + path.join( + ctx.home, + '.codeium', + 'windsurf', + 'memories', + 'global_rules.md' + ), + projectPath: (ctx) => + path.join(ctx.cwd, '.windsurf', 'rules', 'firecrawl.md'), + }, + detectPaths: (ctx) => [path.join(ctx.home, '.codeium', 'windsurf')], + }, + zed: { + id: 'zed', + name: 'Zed', + format: 'json', + serversKey: 'context_servers', + globalConfigPath: (ctx) => path.join(zedUserDir(ctx), 'settings.json'), + projectConfigPath: (ctx) => path.join(ctx.cwd, '.zed', 'settings.json'), + buildEntry: () => ({ url: FIRECRAWL_MCP_URL }), + // Zed sends header values verbatim without expanding variables, so an + // indirect reference would not resolve. Keyless is the only safe option. + supportsEnvAuth: false, + // Zed has no rules mechanism. + detectPaths: (ctx) => [zedUserDir(ctx)], + }, +}; + +export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ + 'claude', + 'cursor', + 'vscode', + 'codex', + 'opencode', + 'windsurf', + 'zed', +]; + +export const MCP_LAUNCHER_NAMES: Record = { + hermes: 'Hermes Agent', + openclaw: 'OpenClaw', +}; + +export const ALL_MCP_LAUNCHER_IDS: readonly McpLauncherId[] = [ + 'hermes', + '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. + */ +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 (existsSync(path.join(entry, `${name}${extension}`))) return true; + } + } + return false; +} + +const LAUNCHER_DETECT: Record boolean> = { + hermes: (ctx) => + existsSync(path.join(ctx.home, '.hermes')) || binaryOnPath('hermes', ctx), + 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', + windsurf: 'windsurf', + zed: 'zed', +}; + +export function resolveMcpClientId(agent: string): McpClientId | undefined { + return CLIENT_ALIASES[agent.trim().toLowerCase()]; +} + +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..abe71f2e6a --- /dev/null +++ b/src/utils/mcp-install.ts @@ -0,0 +1,339 @@ +/** + * 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 (Zed and VS Code ship commented settings, which plain `JSON.parse` + * rejects outright), TOML tables are replaced line by line, 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 { + MCP_CLIENTS, + MCP_SERVER_NAME, + RULE_MARKER, + type McpAuthMode, + type McpClient, + type McpClientId, + type McpContext, + type McpScope, + 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 +): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, 'utf8'); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * 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 raw = await readIfExists(filePath); + + if (raw === undefined || raw.trim() === '') { + const fresh = { [serversKey]: { [serverName]: entry } }; + await writeFileEnsuringDir(filePath, `${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, applyEdits(raw, edits)); + return { status: alreadyExists ? 'reconfigured' : 'configured' }; +} + +/** + * Insert or replace the `[mcp_servers.]` table. Any 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 block = [ + `[mcp_servers.${serverName}]`, + ...Object.entries(entry).map( + ([key, value]) => `${key} = ${JSON.stringify(value)}` + ), + ]; + + const lines = content === '' ? [] : content.split('\n'); + const escaped = escapeRegExp(serverName); + const ownTable = new RegExp( + `^[ \\t]*\\[mcp_servers\\.${escaped}(\\.[^\\]]+)?\\][ \\t]*(?:#.*)?$` + ); + const anyTable = /^[ \t]*\[/; + + const start = lines.findIndex((line) => ownTable.test(line)); + + if (start === -1) { + // Tables must follow root-level keys, so append at the end of the file. + const trimmed = [...lines]; + while (trimmed.length > 0 && trimmed[trimmed.length - 1].trim() === '') { + trimmed.pop(); + } + const separator = trimmed.length === 0 ? [] : ['']; + return { + content: [...trimmed, ...separator, ...block, ''].join('\n'), + alreadyExists: false, + }; + } + + let end = start + 1; + while (end < lines.length) { + if (anyTable.test(lines[end]) && !ownTable.test(lines[end])) break; + end += 1; + } + + const rest = lines.slice(end); + // Keep a blank line between our block and whatever follows it. + const separator = rest.length > 0 && rest[0].trim() !== '' ? [''] : []; + const replaced = [ + ...lines.slice(0, start), + ...block, + ...separator, + ...rest, + ].join('\n'); + + // Consuming the old table can swallow the file's final newline; restoring it + // keeps repeat runs byte-identical. + return { + content: replaced.endsWith('\n') ? replaced : `${replaced}\n`, + alreadyExists: true, + }; +} + +/** 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 section = `${RULE_MARKER}\n${content}${RULE_MARKER}`; + const existing = (await readIfExists(filePath)) ?? ''; + const marker = escapeRegExp(RULE_MARKER); + const fenced = new RegExp(`${marker}\\n[\\s\\S]*?${marker}`); + + if (fenced.test(existing)) { + await writeFileEnsuringDir(filePath, existing.replace(fenced, section)); + return 'updated'; + } + + const separator = + existing.length === 0 ? '' : existing.endsWith('\n') ? '\n' : '\n\n'; + await writeFileEnsuringDir(filePath, `${existing}${separator}${section}\n`); + return 'installed'; +} + +function configPathFor(client: McpClient, scope: McpScope, ctx: McpContext) { + // Agents without project support always take the global path. + const projectPath = client.projectConfigPath?.(ctx); + return scope === 'project' && projectPath + ? projectPath + : client.globalConfigPath(ctx); +} + +async function writeMcpEntry( + client: McpClient, + scope: McpScope, + ctx: McpContext +): Promise<{ status: 'configured' | 'reconfigured'; configPath: string }> { + const configPath = configPathFor(client, scope, ctx); + const entry = client.buildEntry(ctx); + + 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; + } + const { content, alreadyExists } = upsertTomlServer( + existing, + MCP_SERVER_NAME, + stringEntry + ); + await writeFileEnsuringDir(configPath, content); + return { + status: alreadyExists ? 'reconfigured' : 'configured', + configPath, + }; + } + + const { status } = await writeJsonServerEntry( + configPath, + client.serversKey, + MCP_SERVER_NAME, + entry + ); + return { status, configPath }; +} + +async function writeRule( + client: McpClient, + scope: McpScope, + ctx: McpContext +): Promise<{ status: 'installed' | 'updated' | 'unsupported'; path: string }> { + const rule = client.rule; + if (!rule) return { status: 'unsupported', path: '' }; + + const projectPath = rule.projectPath?.(ctx); + const rulePath = + scope === 'project' && projectPath ? projectPath : rule.globalPath(ctx); + const status = + rule.kind === 'file' + ? await writeRuleFile(rulePath, rule.content) + : await appendRuleSection(rulePath, rule.content); + return { status, path: 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: { scope: McpScope; rules: boolean; ctx: McpContext } +): Promise { + const client = MCP_CLIENTS[id]; + // An agent with no verified environment-variable syntax falls back to the + // keyless endpoint rather than having a credential written literally. + const auth: McpAuthMode = + options.ctx.auth === 'env' && client.supportsEnvAuth ? 'env' : 'keyless'; + const ctx: McpContext = { ...options.ctx, auth }; + + const result: McpClientResult = { + id, + name: client.name, + mcpStatus: 'failed', + mcpDetail: '', + auth, + ruleStatus: 'skipped', + ruleDetail: '', + }; + + try { + const { status, configPath } = await writeMcpEntry( + client, + options.scope, + ctx + ); + result.mcpStatus = status; + result.mcpDetail = configPath; + } catch (error) { + result.mcpDetail = error instanceof Error ? error.message : String(error); + } + + if (!options.rules) return result; + + try { + const { status, path: rulePath } = await writeRule( + client, + options.scope, + ctx + ); + result.ruleStatus = status; + result.ruleDetail = rulePath; + } catch (error) { + result.ruleStatus = 'failed'; + result.ruleDetail = error instanceof Error ? error.message : String(error); + } + + return result; +} From a9f9a92fc17366462fd2c4741cc995f98812cf1e Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 12:21:14 -0700 Subject: [PATCH 02/35] fix(cli): detect Hermes Agent by config directory only The PATH lookup matched any executable named `hermes`, including an unrelated JavaScript engine that ships with common toolchains, so the picker pre-selected an agent the user did not have. Detection now prefers a false negative to a false positive: every agent is listed either way, so missing one costs a keystroke while pre-selecting a missing one is misleading. Also pins HOME and PATH for setup tests. Both feed agent detection, so leaving the real ones visible made results depend on what happened to be installed on the machine running the suite. --- src/__tests__/commands/setup.test.ts | 13 +++++++++---- src/utils/mcp-clients.ts | 12 ++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 23db1c016b..e3fb0ae4c4 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -54,6 +54,7 @@ describe('handleSetupCommand', () => { let originalHome: string | undefined; let originalApiKey: string | undefined; let sandboxHome: string; + let originalPath: string | undefined; beforeEach(() => { vi.clearAllMocks(); @@ -69,10 +70,15 @@ describe('handleSetupCommand', () => { // 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; + // Launcher detection also looks on PATH, so pin it for the same reason. + originalPath = process.env.PATH; + process.env.PATH = ''; }); afterEach(() => { rmSync(sandboxHome, { recursive: true, force: true }); + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; if (originalApiKey === undefined) delete process.env.FIRECRAWL_API_KEY; @@ -371,7 +377,7 @@ describe('handleSetupCommand', () => { }); it('offers launchers in the picker and configures Hermes by flag', async () => { - await handleSetupCommand('mcp', { hermes: true, yes: true } as never); + await handleSetupCommand('mcp', { clients: ['hermes'], yes: true }); expect( readFileSync(path.join(sandboxHome, '.hermes', 'config.yaml'), 'utf-8') @@ -401,10 +407,9 @@ describe('handleSetupCommand', () => { }); await handleSetupCommand('mcp', { - cursor: true, - openclaw: true, + clients: ['cursor', 'openclaw'], yes: true, - } as never); + }); expect( JSON.parse( diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 7a30022ccc..9b643ddaed 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -385,9 +385,17 @@ function binaryOnPath(name: string, ctx: McpContext): boolean { return false; } +/** + * Detection prefers a false negative to a false positive: every agent is listed + * in the picker either way, so failing to pre-select one costs a keystroke, + * while pre-selecting an agent the user does not have is misleading. + * + * `hermes` is therefore matched on its config directory alone. The name is also + * used by an unrelated JavaScript engine that ships with common toolchains, so + * a PATH lookup reports it present on machines that do not have this agent. + */ const LAUNCHER_DETECT: Record boolean> = { - hermes: (ctx) => - existsSync(path.join(ctx.home, '.hermes')) || binaryOnPath('hermes', ctx), + hermes: (ctx) => existsSync(path.join(ctx.home, '.hermes')), openclaw: (ctx) => existsSync(path.join(ctx.home, '.openclaw')) || binaryOnPath('openclaw', ctx), From f87761a89e308b0d4a255eec414bade118981b78 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 12:35:38 -0700 Subject: [PATCH 03/35] feat(cli): drop Zed from MCP setup and show every agent in the picker Zed's native remote MCP support is version-gated and its handling of request headers is inconsistent across releases, so a written entry can report success while the agent never connects. That reads as Firecrawl being broken, which is worse than not offering the agent at all. Removing it until the shape can be confirmed against a live install. Also pins the picker page size to the number of agents. The default was smaller than the list, so the last agent scrolled out of view. --- README.md | 2 +- src/__tests__/utils/mcp-install.test.ts | 23 +++++------------------ src/commands/setup.ts | 3 +++ src/utils/agents.ts | 4 ++-- src/utils/mcp-clients.ts | 24 +----------------------- src/utils/mcp-install.ts | 2 +- 6 files changed, 13 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index f50d035429..c06b9e5596 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ firecrawl setup mcp This detects which agents you have installed, pre-selects them in a picker, 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, Windsurf, Zed, Hermes Agent, and OpenClaw. +OpenCode, Windsurf, Hermes Agent, and OpenClaw. Pass agent flags to skip the picker, `-y` to configure every detected agent (MCP only), or `--project` to write to the current project instead of your diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index bb4c1e064c..51cad1cdf3 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -65,23 +65,21 @@ describe('mcp install', () => { writeFileSync( file, [ - '// Zed settings', + '// editor settings', '{', ' "theme": "One Dark",', ' // keep me', ' "buffer_font_size": 15,', - ' "context_servers": { "other": { "url": "https://example.com" } }', + ' "servers": { "other": { "url": "https://example.com" } }', '}', '', ].join('\n') ); - await writeJsonServerEntry(file, 'context_servers', 'fc', { - url: MCP_URL, - }); + await writeJsonServerEntry(file, 'servers', 'fc', { url: MCP_URL }); const result = read(file); - expect(result).toContain('// Zed settings'); + expect(result).toContain('// editor settings'); expect(result).toContain('// keep me'); expect(result).toContain('"theme": "One Dark"'); expect(result).toContain('"other"'); @@ -266,7 +264,7 @@ describe('mcp install', () => { }); it('falls back to keyless for agents that cannot expand variables', async () => { - for (const id of ['zed', 'windsurf'] as const) { + for (const id of ['windsurf'] as const) { const result = await setupMcpClient(id, { scope: 'global', rules: false, @@ -309,17 +307,6 @@ describe('mcp install', () => { ); }); - it('marks rules unsupported for agents without a rules mechanism', async () => { - const result = await setupMcpClient('zed', { - scope: 'global', - rules: true, - ctx, - }); - - expect(result.mcpStatus).toBe('configured'); - expect(result.ruleStatus).toBe('unsupported'); - }); - it('still configures MCP when the rule write fails', async () => { // A file where the rules directory needs to be blocks the rule write. const rulesPath = path.join(ctx.home, '.cursor', 'rules'); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 25f23f9f60..ad8c2f1303 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -592,6 +592,9 @@ async function pickMcpClients( return checkbox({ message: 'Which agents do you want to set up?', loop: false, + // Show every agent at once; the default page size would scroll the last + // ones out of view. + pageSize: ALL_MCP_TARGET_IDS.length, choices: ALL_MCP_TARGET_IDS.map((id) => ({ name: mcpTargetName(id), value: id, diff --git a/src/utils/agents.ts b/src/utils/agents.ts index ea0d50d290..7ee913f076 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -168,9 +168,9 @@ async function fileHasFirecrawlMcp(filePath: string): Promise { /** * Keys under which agents store their MCP server map: `mcpServers` for Claude - * Code, Cursor, and Windsurf, `servers` for VS Code, `context_servers` for Zed. + * Code, Cursor, and Windsurf; `servers` for VS Code. */ -const SERVER_MAP_KEYS = new Set(['mcpServers', 'servers', 'context_servers']); +const SERVER_MAP_KEYS = new Set(['mcpServers', 'servers']); /** * Walk a parsed JSON config looking for a server map (or `mcp.servers`) that diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 9b643ddaed..8e862148a1 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -26,8 +26,7 @@ export type McpClientId = | 'vscode' | 'codex' | 'opencode' - | 'windsurf' - | 'zed'; + | 'windsurf'; export type McpScope = 'global' | 'project'; @@ -153,11 +152,6 @@ function vscodeUserDir(ctx: McpContext): string { return path.join(appSupportDir(ctx, 'Code'), 'User'); } -function zedUserDir(ctx: McpContext): string { - if (ctx.platform === 'win32') return appSupportDir(ctx, 'Zed'); - return path.join(ctx.home, '.config', 'zed'); -} - /** Attach the agent's env-reference header when authenticating that way. */ function withEnvAuth( ctx: McpContext, @@ -315,20 +309,6 @@ export const MCP_CLIENTS: Record = { }, detectPaths: (ctx) => [path.join(ctx.home, '.codeium', 'windsurf')], }, - zed: { - id: 'zed', - name: 'Zed', - format: 'json', - serversKey: 'context_servers', - globalConfigPath: (ctx) => path.join(zedUserDir(ctx), 'settings.json'), - projectConfigPath: (ctx) => path.join(ctx.cwd, '.zed', 'settings.json'), - buildEntry: () => ({ url: FIRECRAWL_MCP_URL }), - // Zed sends header values verbatim without expanding variables, so an - // indirect reference would not resolve. Keyless is the only safe option. - supportsEnvAuth: false, - // Zed has no rules mechanism. - detectPaths: (ctx) => [zedUserDir(ctx)], - }, }; export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ @@ -338,7 +318,6 @@ export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ 'codex', 'opencode', 'windsurf', - 'zed', ]; export const MCP_LAUNCHER_NAMES: Record = { @@ -422,7 +401,6 @@ const CLIENT_ALIASES: Record = { opencode: 'opencode', 'open-code': 'opencode', windsurf: 'windsurf', - zed: 'zed', }; export function resolveMcpClientId(agent: string): McpClientId | undefined { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index abe71f2e6a..5799d7bb31 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -4,7 +4,7 @@ * * 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 (Zed and VS Code ship commented settings, which plain `JSON.parse` + * intact (several agents ship commented settings, which plain `JSON.parse` * rejects outright), TOML tables are replaced line by line, and shared rule * files get a marker-fenced section rather than a rewrite. */ From daeefcafd451332bf9e53c1ca098d8973ab80f23 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 13:12:04 -0700 Subject: [PATCH 04/35] feat(cli): drop Windsurf from MCP setup Windsurf's remote entry shape is not settled: sources disagree on whether a transport field is required and what its value should be, and one reports streamable HTTP working only through a local proxy. A wrong entry does not error, it reports success and then exposes no tools, so this stays out until the shape can be confirmed against a live install. With every supported agent now carrying a verified environment-reference syntax and project-level config, the keyless-fallback and global-fallback branches no longer have a case. Removing them rather than leaving unreachable logic behind; they come back with the agent that needs them. --- README.md | 5 +-- src/__tests__/utils/mcp-install.test.ts | 44 ---------------------- src/commands/setup.ts | 6 +-- src/utils/mcp-clients.ts | 50 +------------------------ src/utils/mcp-install.ts | 8 +--- 5 files changed, 6 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index c06b9e5596..0b72794bba 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ firecrawl setup mcp This detects which agents you have installed, pre-selects them in a picker, 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, Windsurf, Hermes Agent, and OpenClaw. +OpenCode, Hermes Agent, and OpenClaw. Pass agent flags to skip the picker, `-y` to configure every detected agent (MCP only), or `--project` to write to the current project instead of your @@ -106,9 +106,6 @@ 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. -Not every agent supports project-level MCP configuration. Those agents always -receive the global configuration. - To make Firecrawl the default web provider for supported AI agents: ```bash diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 51cad1cdf3..ac6e685f74 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -263,50 +263,6 @@ describe('mcp install', () => { expect(config).toContain('bearer_token_env_var = "FIRECRAWL_API_KEY"'); }); - it('falls back to keyless for agents that cannot expand variables', async () => { - for (const id of ['windsurf'] as const) { - const result = await setupMcpClient(id, { - scope: 'global', - rules: false, - ctx: { ...ctx, auth: 'env' }, - }); - - expect(result.auth).toBe('keyless'); - expect(read(result.mcpDetail)).not.toContain('Authorization'); - } - }); - - it('honours CLAUDE_CONFIG_DIR', async () => { - const configDir = path.join(root, 'claude-config'); - - const result = await setupMcpClient('claude', { - scope: 'global', - 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('falls back to global config for agents without project support', async () => { - const result = await setupMcpClient('windsurf', { - scope: 'project', - rules: true, - ctx, - }); - - // MCP is global-only for Windsurf; the rule still lands in the project. - expect(result.mcpDetail).toBe( - path.join(ctx.home, '.codeium', 'windsurf', 'mcp_config.json') - ); - expect(result.ruleDetail).toBe( - path.join(ctx.cwd, '.windsurf', 'rules', 'firecrawl.md') - ); - }); - it('still configures MCP when the rule write fails', async () => { // A file where the rules directory needs to be blocks the rule write. const rulesPath = path.join(ctx.home, '.cursor', 'rules'); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index ad8c2f1303..9fe5844099 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -762,11 +762,7 @@ function authNotes( ]; } - const keyless = succeeded.filter((result) => result.auth === 'keyless'); - if (keyless.length === 0) return []; - return [ - `${keyless.map((result) => result.name).join(' and ')} cannot expand environment variables in MCP config, so ${keyless.length > 1 ? 'they were' : 'it was'} configured keyless.`, - ]; + return []; } function reportMcpResults( diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 8e862148a1..fb994b60da 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -9,8 +9,7 @@ * 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. Agents without a verified syntax get the keyless endpoint, which - * still serves search, scrape, and parse under an anonymous rate limit. + * expand. An agent is only supported once that syntax is verified. */ import { existsSync, promises as fs } from 'fs'; @@ -20,13 +19,7 @@ export const FIRECRAWL_MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; export const MCP_SERVER_NAME = 'firecrawl'; export const API_KEY_ENV_VAR = 'FIRECRAWL_API_KEY'; -export type McpClientId = - | 'claude' - | 'cursor' - | 'vscode' - | 'codex' - | 'opencode' - | 'windsurf'; +export type McpClientId = 'claude' | 'cursor' | 'vscode' | 'codex' | 'opencode'; export type McpScope = 'global' | 'project'; @@ -75,11 +68,6 @@ export interface McpClient { /** Absent when the agent only supports global MCP configuration. */ projectConfigPath?: (ctx: McpContext) => string; buildEntry: (ctx: McpContext) => Record; - /** - * True when this agent can authenticate without a literal key: either it - * expands an env reference in headers, or it resolves the variable natively. - */ - supportsEnvAuth: boolean; /** Absent when the agent has no rules mechanism. */ rule?: McpRuleSpec; /** Paths whose existence means the agent is installed. */ @@ -176,7 +164,6 @@ export const MCP_CLIENTS: Record = { { type: 'http', url: FIRECRAWL_MCP_URL }, ENV_HEADER.shell ), - supportsEnvAuth: true, rule: { kind: 'file', content: RULE_BODY, @@ -196,7 +183,6 @@ export const MCP_CLIENTS: Record = { projectConfigPath: (ctx) => path.join(ctx.cwd, '.cursor', 'mcp.json'), buildEntry: (ctx) => withEnvAuth(ctx, { url: FIRECRAWL_MCP_URL }, ENV_HEADER.editor), - supportsEnvAuth: true, rule: { kind: 'file', content: CURSOR_RULE, @@ -220,7 +206,6 @@ export const MCP_CLIENTS: Record = { { type: 'http', url: FIRECRAWL_MCP_URL }, ENV_HEADER.editor ), - supportsEnvAuth: true, rule: { kind: 'file', content: VSCODE_RULE, @@ -249,7 +234,6 @@ export const MCP_CLIENTS: Record = { ctx.auth === 'env' ? { url: FIRECRAWL_MCP_URL, bearer_token_env_var: API_KEY_ENV_VAR } : { url: FIRECRAWL_MCP_URL }, - supportsEnvAuth: true, rule: { kind: 'append', content: RULE_BODY, @@ -272,7 +256,6 @@ export const MCP_CLIENTS: Record = { { type: 'remote', url: FIRECRAWL_MCP_URL, enabled: true }, ENV_HEADER.brace ), - supportsEnvAuth: true, rule: { kind: 'append', content: RULE_BODY, @@ -282,33 +265,6 @@ export const MCP_CLIENTS: Record = { }, detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], }, - windsurf: { - id: 'windsurf', - name: 'Windsurf', - format: 'json', - serversKey: 'mcpServers', - // Windsurf has no project-level MCP config; it always gets the global one. - globalConfigPath: (ctx) => - path.join(ctx.home, '.codeium', 'windsurf', 'mcp_config.json'), - buildEntry: () => ({ serverUrl: FIRECRAWL_MCP_URL }), - // No verified env-reference syntax, so this agent stays keyless. - supportsEnvAuth: false, - rule: { - kind: 'append', - content: RULE_BODY, - globalPath: (ctx) => - path.join( - ctx.home, - '.codeium', - 'windsurf', - 'memories', - 'global_rules.md' - ), - projectPath: (ctx) => - path.join(ctx.cwd, '.windsurf', 'rules', 'firecrawl.md'), - }, - detectPaths: (ctx) => [path.join(ctx.home, '.codeium', 'windsurf')], - }, }; export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ @@ -317,7 +273,6 @@ export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ 'vscode', 'codex', 'opencode', - 'windsurf', ]; export const MCP_LAUNCHER_NAMES: Record = { @@ -400,7 +355,6 @@ const CLIENT_ALIASES: Record = { 'codex-gui': 'codex', opencode: 'opencode', 'open-code': 'opencode', - windsurf: 'windsurf', }; export function resolveMcpClientId(agent: string): McpClientId | undefined { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 5799d7bb31..4eab996c66 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -292,18 +292,14 @@ export async function setupMcpClient( options: { scope: McpScope; rules: boolean; ctx: McpContext } ): Promise { const client = MCP_CLIENTS[id]; - // An agent with no verified environment-variable syntax falls back to the - // keyless endpoint rather than having a credential written literally. - const auth: McpAuthMode = - options.ctx.auth === 'env' && client.supportsEnvAuth ? 'env' : 'keyless'; - const ctx: McpContext = { ...options.ctx, auth }; + const ctx = options.ctx; const result: McpClientResult = { id, name: client.name, mcpStatus: 'failed', mcpDetail: '', - auth, + auth: ctx.auth, ruleStatus: 'skipped', ruleDetail: '', }; From f68096c699cc644939acc3beb60c4043f8b47480 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 14:19:41 -0700 Subject: [PATCH 05/35] fix(cli): correct config-writing edge cases found in review Six defects, four of them silent: * Quiet mode returned before the total-failure check, so a run in which nothing was written resolved successfully. `firecrawl init` and `firecrawl launch` both use quiet mode and reported success regardless. * The TOML writer split on "\n" only, so a config.toml with CRLF endings never matched its existing table and gained a duplicate one, leaving the file invalid and taking the rest of the user's Codex config with it. * The TOML writer absorbed comment and blank lines directly above the next table into the replaced range and deleted them. * A leading byte order mark was reported as a parse error even though the document parses, so a config written by a Windows editor was refused. * The rule fence required "\n" after its marker, so a file converted to CRLF gained a second copy of the section instead of an updated one. * `--agent all` reached only detected clients. It means every client, which is what the installer it replaced did. The fence replacement now uses a function so nothing in the rule body can be read as a replacement pattern. Line endings and byte order marks are preserved on write rather than normalised away. --- .codex/config.toml | 2 + .mcp.json | 8 +++ opencode.json | 9 ++++ src/__tests__/commands/setup.test.ts | 30 +++++++++++ src/__tests__/utils/mcp-install.test.ts | 67 +++++++++++++++++++++++++ src/commands/setup.ts | 15 ++++-- src/utils/mcp-install.ts | 39 ++++++++++---- 7 files changed, 158 insertions(+), 12 deletions(-) create mode 100644 .codex/config.toml create mode 100644 .mcp.json create mode 100644 opencode.json diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000000..51bc8d23ee --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,2 @@ +[mcp_servers.firecrawl] +url = "https://mcp.firecrawl.dev/v2/mcp" diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..ba95cbffd3 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "firecrawl": { + "type": "http", + "url": "https://mcp.firecrawl.dev/v2/mcp" + } + } +} diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000000..79d0161acd --- /dev/null +++ b/opencode.json @@ -0,0 +1,9 @@ +{ + "mcp": { + "firecrawl": { + "type": "remote", + "url": "https://mcp.firecrawl.dev/v2/mcp", + "enabled": true + } + } +} diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index e3fb0ae4c4..24c5c43ee5 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -418,6 +418,36 @@ describe('handleSetupCommand', () => { ).toBe(MCP_URL); }); + 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('configures every client with --agent all, detected or not', async () => { + await handleSetupCommand('mcp', { + agent: 'all', + global: true, + yes: true, + keyless: true, + }); + + for (const id of [ + 'claude', + 'cursor', + 'codex', + 'vscode', + 'opencode', + ] as const) { + expect(existsSync(globalConfigPath(id, sandboxHome))).toBe(true); + } + }); + it('rejects a stored key before writing Hermes MCP config', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-hermes-test-')); process.env.HOME = home; diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index ac6e685f74..adff97415e 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -86,6 +86,24 @@ describe('mcp install', () => { 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( @@ -170,6 +188,41 @@ describe('mcp install', () => { 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', { @@ -197,6 +250,20 @@ describe('mcp install', () => { }); }); + 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'); + }); + }); + describe('setupMcpClient', () => { it('writes the keyless URL with no credentials', async () => { const result = await setupMcpClient('cursor', { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 9fe5844099..681fefde57 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -29,6 +29,7 @@ import { type WebAgent, } from '../utils/web-defaults'; import { + ALL_MCP_CLIENT_IDS, ALL_MCP_LAUNCHER_IDS, ALL_MCP_TARGET_IDS, detectMcpClients, @@ -673,7 +674,9 @@ async function installMcpClients( // Prompts only make sense when someone is there to answer them. const nonInteractive = Boolean(options.yes) || !process.stdin.isTTY; - let selected = explicitIds ?? options.clients; + let selected = includeAllLaunchers + ? [...ALL_MCP_CLIENT_IDS] + : (explicitIds ?? options.clients); if (!selected || selected.length === 0) { const detected: McpTargetId[] = [ ...(await detectMcpClients(ctx)), @@ -695,8 +698,8 @@ async function installMcpClients( } } - // `--agent all` reaches every launch integration whether or not it looks - // installed, which is what the flag has always meant. + // `--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)), @@ -781,6 +784,12 @@ function reportMcpResults( : ` ${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; } diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 4eab996c66..4310565fb0 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -81,11 +81,19 @@ export async function writeJsonServerEntry( serverName: string, entry: Record ): Promise<{ status: 'configured' | 'reconfigured' }> { - const raw = await readIfExists(filePath); + 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, `${JSON.stringify(fresh, null, 2)}\n`); + await writeFileEnsuringDir( + filePath, + `${bom}${JSON.stringify(fresh, null, 2)}\n` + ); return { status: 'configured' }; } @@ -119,7 +127,7 @@ export async function writeJsonServerEntry( { formattingOptions: { insertSpaces: true, tabSize: 2 } } ); - await writeFileEnsuringDir(filePath, applyEdits(raw, edits)); + await writeFileEnsuringDir(filePath, `${bom}${applyEdits(raw, edits)}`); return { status: alreadyExists ? 'reconfigured' : 'configured' }; } @@ -142,7 +150,10 @@ export function upsertTomlServer( ), ]; - const lines = content === '' ? [] : content.split('\n'); + // Preserve the file's existing line ending; a CRLF config must not be + // treated as one unmatchable line per table. + const eol = content.includes('\r\n') ? '\r\n' : '\n'; + const lines = content === '' ? [] : content.split(/\r?\n/); const escaped = escapeRegExp(serverName); const ownTable = new RegExp( `^[ \\t]*\\[mcp_servers\\.${escaped}(\\.[^\\]]+)?\\][ \\t]*(?:#.*)?$` @@ -159,7 +170,7 @@ export function upsertTomlServer( } const separator = trimmed.length === 0 ? [] : ['']; return { - content: [...trimmed, ...separator, ...block, ''].join('\n'), + content: [...trimmed, ...separator, ...block, ''].join(eol), alreadyExists: false, }; } @@ -169,6 +180,11 @@ export function upsertTomlServer( if (anyTable.test(lines[end]) && !ownTable.test(lines[end])) break; end += 1; } + // Comments and blank lines directly above the next table introduce it, so + // they belong to the user's content rather than to the block being replaced. + while (end - 1 > start && /^[ \t]*(#.*)?$/.test(lines[end - 1])) { + end -= 1; + } const rest = lines.slice(end); // Keep a blank line between our block and whatever follows it. @@ -178,12 +194,12 @@ export function upsertTomlServer( ...block, ...separator, ...rest, - ].join('\n'); + ].join(eol); // Consuming the old table can swallow the file's final newline; restoring it // keeps repeat runs byte-identical. return { - content: replaced.endsWith('\n') ? replaced : `${replaced}\n`, + content: replaced.endsWith(eol) ? replaced : `${replaced}${eol}`, alreadyExists: true, }; } @@ -209,10 +225,15 @@ export async function appendRuleSection( const section = `${RULE_MARKER}\n${content}${RULE_MARKER}`; const existing = (await readIfExists(filePath)) ?? ''; const marker = escapeRegExp(RULE_MARKER); - const fenced = new RegExp(`${marker}\\n[\\s\\S]*?${marker}`); + const fenced = new RegExp(`${marker}\\r?\\n[\\s\\S]*?${marker}`); if (fenced.test(existing)) { - await writeFileEnsuringDir(filePath, existing.replace(fenced, section)); + // Replace via a function so nothing in the rule body is read as a + // replacement pattern. + await writeFileEnsuringDir( + filePath, + existing.replace(fenced, () => section) + ); return 'updated'; } From 91621319f2daa8f358d472e16ec1b2f37ed25035 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 14:50:26 -0700 Subject: [PATCH 06/35] fix(cli): keep skills-only agents from failing setup, and settle the scope flag `firecrawl setup --yes --agent windsurf` installed skills and then aborted, because MCP setup rejected a name it writes no config for. An agent we support for skills but not for MCP is not an error: the run now finishes, skips the MCP step, and prints the server URL so the user can wire it up themselves. A name nothing supports is still rejected, so a typo does not silently do nothing. Scope: global is the intended default, so that one command reaches every agent surface rather than the current checkout alone. `--project` is the only scope flag that means anything on setup. `-g` is accepted for existing scripts but hidden from help and reported as deprecated when used, and the mutually exclusive scope error it existed for is gone. `-g` is untouched on init and launch. Tests also pin USERPROFILE and APPDATA alongside HOME. os.homedir() reads USERPROFILE on Windows, so the sandbox that keeps a test run away from the developer's own agent config was doing nothing there. --- README.md | 5 ++-- src/__tests__/commands/setup.test.ts | 42 ++++++++++++++++++++-------- src/commands/setup.ts | 34 +++++++++++++++------- src/commands/skills-native.ts | 5 ++++ src/index.ts | 12 +++++++- 5 files changed, 74 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0b72794bba..f8cb7d10d6 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,10 @@ 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 by default, so one command puts +Firecrawl on every agent you already use rather than only the current checkout. Pass agent flags to skip the picker, `-y` to configure every detected agent -(MCP only), or `--project` to write to the current project instead of your -global agent settings: +(MCP only), or `--project` to scope the change to this repository: ```bash firecrawl setup mcp --claude --cursor # skip the picker diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 24c5c43ee5..3e28c80f51 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -55,6 +55,8 @@ describe('handleSetupCommand', () => { let originalApiKey: string | undefined; let sandboxHome: string; let originalPath: string | undefined; + let originalUserProfile: string | undefined; + let originalAppData: string | undefined; beforeEach(() => { vi.clearAllMocks(); @@ -70,6 +72,12 @@ describe('handleSetupCommand', () => { // 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'); // Launcher detection also looks on PATH, so pin it for the same reason. originalPath = process.env.PATH; process.env.PATH = ''; @@ -79,6 +87,10 @@ describe('handleSetupCommand', () => { rmSync(sandboxHome, { recursive: true, force: true }); if (originalPath === undefined) delete process.env.PATH; else process.env.PATH = originalPath; + 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; @@ -448,6 +460,25 @@ describe('handleSetupCommand', () => { } }); + 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('still rejects an agent name nothing supports', async () => { + await expect( + handleSetupCommand('mcp', { agent: 'not-an-agent', yes: true }) + ).rejects.toThrow('Unknown agent'); + }); + it('rejects a stored key before writing Hermes MCP config', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-hermes-test-')); process.env.HOME = home; @@ -793,17 +824,6 @@ describe('handleSetupCommand', () => { // --- 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'; const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-env-')); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 681fefde57..54e004b6c4 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -22,7 +22,11 @@ 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, @@ -30,6 +34,7 @@ import { } from '../utils/web-defaults'; import { ALL_MCP_CLIENT_IDS, + FIRECRAWL_MCP_URL, ALL_MCP_LAUNCHER_IDS, ALL_MCP_TARGET_IDS, detectMcpClients, @@ -51,6 +56,7 @@ type SetupIntegration = SetupSubcommand; type ResolvedMcpAgent = | { kind: 'clients'; ids?: McpTargetId[] } + | { kind: 'skills-only'; agent: string } | { kind: 'hermes' } | { kind: 'openclaw' } | { kind: 'all-launchers' }; @@ -266,12 +272,15 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { return { kind: 'openclaw' }; default: { const id = resolveMcpClientId(normalized); - if (!id) { - throw new Error( - `Unknown agent "${agent}" for setup mcp. Use one of: ${ALL_MCP_TARGET_IDS.join(', ')}, all.` - ); + 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 }; } - return { kind: 'clients', ids: [id] }; + throw new Error( + `Unknown agent "${agent}" for setup mcp. Use one of: ${ALL_MCP_TARGET_IDS.join(', ')}, all.` + ); } } } @@ -544,13 +553,18 @@ export async function installMcp( // 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.'); - } - const apiKey = options.keyless ? undefined : getApiKey(); const resolvedAgent = resolveMcpAgent(options.agent); + 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; + } + if (resolvedAgent.kind === 'hermes') { await installHermesMcp(runtimeEnv, options.keyless); return; 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 a07d9e602e..530dab2fd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2242,7 +2242,6 @@ const setupCommand = 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)' @@ -2269,6 +2268,12 @@ 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)') @@ -2284,6 +2289,11 @@ Examples: ` ) .action(async (subcommand: SetupSubcommand, options) => { + if (options.global) { + console.error( + 'Note: -g/--global is deprecated for setup. Global is the default; use --project for project scope.' + ); + } await handleSetupCommand(subcommand, { ...options, clients: ALL_MCP_TARGET_IDS.filter((id) => options[id] === true), From 5fb50b579520f7596182963443e346899144fa8e Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 16:37:21 -0700 Subject: [PATCH 07/35] fix(cli): keep MCP setup global-only and keyless for stored launcher keys Project scope fought the one-command-every-agent goal, and --agent hermes/openclaw aborted on a stored key while the boolean flags wrote keyless config. --- README.md | 16 +- src/__tests__/commands/setup.test.ts | 188 +++++++++++------------- src/__tests__/utils/mcp-install.test.ts | 25 +++- src/commands/setup.ts | 37 ++--- src/index.ts | 7 +- src/utils/mcp-clients.ts | 31 +--- src/utils/mcp-install.ts | 31 +--- 7 files changed, 134 insertions(+), 201 deletions(-) diff --git a/README.md b/README.md index f8cb7d10d6..3e9b44da36 100644 --- a/README.md +++ b/README.md @@ -81,21 +81,19 @@ To install the Firecrawl MCP server into your coding agents: firecrawl setup mcp ``` -This detects which agents you have installed, pre-selects them in a picker, 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. +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 by default, so one command puts -Firecrawl on every agent you already use rather than only the current checkout. -Pass agent flags to skip the picker, `-y` to configure every detected agent -(MCP only), or `--project` to scope the change to this repository: +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 -firecrawl setup mcp --project --cursor # write project config ``` Rerun the command any time to update an existing setup or add another agent; it diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 3e28c80f51..ffffc14c07 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -50,6 +50,11 @@ 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; @@ -396,6 +401,44 @@ describe('handleSetupCommand', () => { ).toContain('firecrawl:'); }); + 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, '.hermes'), { recursive: true }); @@ -479,30 +522,30 @@ describe('handleSetupCommand', () => { ).rejects.toThrow('Unknown agent'); }); - it('rejects a stored key before writing Hermes MCP config', async () => { + 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') { @@ -555,12 +598,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'; @@ -642,50 +707,14 @@ describe('handleSetupCommand', () => { } }); - it('keeps an environment-backed --agent all project setup free of literals', async () => { - const home = mkdtempSync( - path.join(os.tmpdir(), 'firecrawl-all-project-env-') - ); - mkdirSync(path.join(home, '.cursor'), { recursive: true }); - process.env.HOME = home; - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-proj-cwd-')); - const originalCwd = process.cwd(); - process.chdir(cwd); - - try { - await handleSetupCommand('mcp', { - agent: 'all', - project: true, - yes: true, - }); - - const config = readFileSync( - path.join(cwd, '.cursor', 'mcp.json'), - 'utf-8' - ); - expect(JSON.parse(config).mcpServers.firecrawl.headers).toEqual({ - Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', - }); - expect(config).not.toContain('fc-test-key'); - } finally { - process.chdir(originalCwd); - rmSync(cwd, { recursive: true, force: true }); - 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, }); @@ -822,60 +851,7 @@ describe('handleSetupCommand', () => { } }); - // --- Scope: project and global are mutually exclusive --- - - it('keeps project scope for an environment-backed credential', async () => { - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-env-')); - const originalCwd = process.cwd(); - process.chdir(cwd); - - try { - await handleSetupCommand('mcp', { - agent: 'cursor', - project: true, - yes: true, - }); - - const config = readFileSync( - path.join(cwd, '.cursor', 'mcp.json'), - 'utf-8' - ); - expect(JSON.parse(config).mcpServers.firecrawl.headers).toEqual({ - Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', - }); - expect(config).not.toContain('fc-test-key'); - } finally { - process.chdir(originalCwd); - rmSync(cwd, { recursive: true, force: true }); - } - }); - - it('writes project scope rather than global when --project is set', async () => { - vi.mocked(getApiKey).mockReturnValue(undefined); - const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-home-')); - process.env.HOME = home; - const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-cwd-')); - const originalCwd = process.cwd(); - process.chdir(cwd); - - try { - await handleSetupCommand('mcp', { - agent: 'cursor', - project: true, - yes: true, - }); - - expect(existsSync(path.join(cwd, '.cursor', 'mcp.json'))).toBe(true); - expect(existsSync(path.join(home, '.cursor', 'mcp.json'))).toBe(false); - } finally { - process.chdir(originalCwd); - rmSync(cwd, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); - } - }); - - it('defaults to global scope without --project', 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; diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index adff97415e..20f1b91609 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -267,7 +267,6 @@ describe('mcp install', () => { describe('setupMcpClient', () => { it('writes the keyless URL with no credentials', async () => { const result = await setupMcpClient('cursor', { - scope: 'global', rules: false, ctx, }); @@ -281,7 +280,6 @@ describe('mcp install', () => { it('references the env var instead of writing a credential', async () => { const result = await setupMcpClient('claude', { - scope: 'global', rules: false, ctx: { ...ctx, auth: 'env' }, }); @@ -297,11 +295,23 @@ describe('mcp install', () => { }); }); + 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, { - scope: 'global', rules: false, ctx: { ...ctx, auth: 'env' }, }); @@ -321,7 +331,6 @@ describe('mcp install', () => { it('authenticates Codex through its native bearer token variable', async () => { await setupMcpClient('codex', { - scope: 'global', rules: false, ctx: { ...ctx, auth: 'env' }, }); @@ -337,7 +346,6 @@ describe('mcp install', () => { writeFileSync(rulesPath, 'not a directory'); const result = await setupMcpClient('cursor', { - scope: 'global', rules: true, ctx, }); @@ -352,7 +360,6 @@ describe('mcp install', () => { writeFileSync(file, '{ oops'); const result = await setupMcpClient('cursor', { - scope: 'global', rules: false, ctx, }); @@ -370,6 +377,12 @@ describe('mcp install', () => { 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']); + }); }); describe('resolveMcpClientId', () => { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 54e004b6c4..669e4a8012 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -45,7 +45,6 @@ import { type McpAuthMode, type McpContext, type McpLauncherId, - type McpScope, type McpTargetId, } from '../utils/mcp-clients'; import { setupMcpClient, type McpClientResult } from '../utils/mcp-install'; @@ -63,8 +62,6 @@ type ResolvedMcpAgent = 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. */ @@ -349,7 +346,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); @@ -555,6 +552,9 @@ export async function installMcp( ): Promise { const apiKey = options.keyless ? undefined : getApiKey(); const resolvedAgent = resolveMcpAgent(options.agent); + // 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 @@ -566,13 +566,11 @@ export async function installMcp( } if (resolvedAgent.kind === 'hermes') { - await installHermesMcp(runtimeEnv, options.keyless); + await installHermesMcp(runtimeEnv, keyless, Boolean(options.quiet)); return; } if (resolvedAgent.kind === 'openclaw') { - // Hands the credential to a subprocess, so a stored key is not usable. - assertSubprocessSafeCredential(apiKey, runtimeEnv); - await installOpenClawMcp(runtimeEnv, options.keyless); + await installOpenClawMcp(runtimeEnv, keyless, Boolean(options.quiet)); return; } if (resolvedAgent.kind === 'all-launchers') { @@ -607,13 +605,11 @@ async function pickMcpClients( return checkbox({ message: 'Which agents do you want to set up?', loop: false, - // Show every agent at once; the default page size would scroll the last - // ones out of view. - pageSize: ALL_MCP_TARGET_IDS.length, - choices: ALL_MCP_TARGET_IDS.map((id) => ({ + pageSize: detected.length, + choices: detected.map((id) => ({ name: mcpTargetName(id), value: id, - checked: detected.includes(id), + checked: true, })), }); } @@ -684,7 +680,6 @@ async function installMcpClients( env: runtimeEnv, auth, }; - const scope: McpScope = options.project ? 'project' : 'global'; // Prompts only make sense when someone is there to answer them. const nonInteractive = Boolean(options.yes) || !process.stdin.isTTY; @@ -696,12 +691,12 @@ async function installMcpClients( ...(await detectMcpClients(ctx)), ...detectMcpLaunchers(ctx), ]; + if (detected.length === 0 && !includeAllLaunchers) { + throw new Error( + 'No coding agents detected. Pass an agent flag such as --claude or --cursor.' + ); + } if (nonInteractive) { - if (detected.length === 0 && !includeAllLaunchers) { - throw new Error( - 'No coding agents detected. Pass an agent flag such as --claude or --cursor.' - ); - } selected = detected; } else { selected = await pickMcpClients(detected); @@ -731,7 +726,7 @@ async function installMcpClients( results.push( isMcpLauncherId(id) ? await setupMcpLauncher(id, ctx, runtimeEnv) - : await setupMcpClient(id, { scope, rules, ctx }) + : await setupMcpClient(id, { rules, ctx }) ); } @@ -769,7 +764,7 @@ function authNotes( if (!hasApiKey) { return [ - 'Running keyless (search, scrape, parse). Run "firecrawl login" and rerun to unlock the full tool surface.', + `Running keyless (search, scrape, parse). Export ${ENV_API_KEY} where your agents run, then rerun to authenticate.`, ]; } diff --git a/src/index.ts b/src/index.ts index 530dab2fd7..2819ffdf77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2242,10 +2242,6 @@ const setupCommand = program '[subcommand]', 'What to set up: "skills", "workflows", "mcp", or "defaults"; omit for an interactive installer' ) - .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' @@ -2285,13 +2281,12 @@ Examples: $ 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 - $ firecrawl setup mcp --project --cursor # write project config ` ) .action(async (subcommand: SetupSubcommand, options) => { if (options.global) { console.error( - 'Note: -g/--global is deprecated for setup. Global is the default; use --project for project scope.' + 'Note: -g/--global is deprecated for setup. Global is the default.' ); } await handleSetupCommand(subcommand, { diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index fb994b60da..36328c3a5b 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -21,8 +21,6 @@ export const API_KEY_ENV_VAR = 'FIRECRAWL_API_KEY'; export type McpClientId = 'claude' | 'cursor' | 'vscode' | 'codex' | 'opencode'; -export type McpScope = 'global' | 'project'; - /** * Agent launchers that own their MCP configuration rather than reading a file * we write. They are offered alongside the editors but installed differently. @@ -55,7 +53,6 @@ export interface McpRuleSpec { kind: 'file' | 'append'; content: string; globalPath: (ctx: McpContext) => string; - projectPath?: (ctx: McpContext) => string; } export interface McpClient { @@ -65,8 +62,6 @@ export interface McpClient { /** Key of the map holding MCP servers in this agent's config. */ serversKey: string; globalConfigPath: (ctx: McpContext) => string; - /** Absent when the agent only supports global MCP configuration. */ - projectConfigPath?: (ctx: McpContext) => string; buildEntry: (ctx: McpContext) => Record; /** Absent when the agent has no rules mechanism. */ rule?: McpRuleSpec; @@ -157,7 +152,6 @@ export const MCP_CLIENTS: Record = { format: 'json', serversKey: 'mcpServers', globalConfigPath: claudeGlobalConfigPath, - projectConfigPath: (ctx) => path.join(ctx.cwd, '.mcp.json'), buildEntry: (ctx) => withEnvAuth( ctx, @@ -169,10 +163,8 @@ export const MCP_CLIENTS: Record = { content: RULE_BODY, globalPath: (ctx) => path.join(claudeConfigDir(ctx), 'rules', 'firecrawl.md'), - projectPath: (ctx) => - path.join(ctx.cwd, '.claude', 'rules', 'firecrawl.md'), }, - detectPaths: (ctx) => [claudeConfigDir(ctx)], + detectPaths: (ctx) => [claudeConfigDir(ctx), claudeGlobalConfigPath(ctx)], }, cursor: { id: 'cursor', @@ -180,7 +172,6 @@ export const MCP_CLIENTS: Record = { format: 'json', serversKey: 'mcpServers', globalConfigPath: (ctx) => path.join(ctx.home, '.cursor', 'mcp.json'), - projectConfigPath: (ctx) => path.join(ctx.cwd, '.cursor', 'mcp.json'), buildEntry: (ctx) => withEnvAuth(ctx, { url: FIRECRAWL_MCP_URL }, ENV_HEADER.editor), rule: { @@ -188,8 +179,6 @@ export const MCP_CLIENTS: Record = { content: CURSOR_RULE, globalPath: (ctx) => path.join(ctx.home, '.cursor', 'rules', 'firecrawl.mdc'), - projectPath: (ctx) => - path.join(ctx.cwd, '.cursor', 'rules', 'firecrawl.mdc'), }, detectPaths: (ctx) => [path.join(ctx.home, '.cursor')], }, @@ -199,7 +188,6 @@ export const MCP_CLIENTS: Record = { format: 'json', serversKey: 'servers', globalConfigPath: (ctx) => path.join(vscodeUserDir(ctx), 'mcp.json'), - projectConfigPath: (ctx) => path.join(ctx.cwd, '.vscode', 'mcp.json'), buildEntry: (ctx) => withEnvAuth( ctx, @@ -211,13 +199,6 @@ export const MCP_CLIENTS: Record = { content: VSCODE_RULE, globalPath: (ctx) => path.join(vscodeUserDir(ctx), 'prompts', 'firecrawl.instructions.md'), - projectPath: (ctx) => - path.join( - ctx.cwd, - '.github', - 'instructions', - 'firecrawl.instructions.md' - ), }, detectPaths: (ctx) => [vscodeUserDir(ctx)], }, @@ -227,7 +208,6 @@ export const MCP_CLIENTS: Record = { format: 'toml', serversKey: 'mcp_servers', globalConfigPath: (ctx) => path.join(ctx.home, '.codex', 'config.toml'), - projectConfigPath: (ctx) => path.join(ctx.cwd, '.codex', 'config.toml'), // Codex resolves the bearer token from the environment by variable name, // so it authenticates without a header template. buildEntry: (ctx) => @@ -238,7 +218,6 @@ export const MCP_CLIENTS: Record = { kind: 'append', content: RULE_BODY, globalPath: (ctx) => path.join(ctx.home, '.codex', 'AGENTS.md'), - projectPath: (ctx) => path.join(ctx.cwd, 'AGENTS.md'), }, detectPaths: (ctx) => [path.join(ctx.home, '.codex')], }, @@ -249,7 +228,6 @@ export const MCP_CLIENTS: Record = { serversKey: 'mcp', globalConfigPath: (ctx) => path.join(ctx.home, '.config', 'opencode', 'opencode.json'), - projectConfigPath: (ctx) => path.join(ctx.cwd, 'opencode.json'), buildEntry: (ctx) => withEnvAuth( ctx, @@ -261,7 +239,6 @@ export const MCP_CLIENTS: Record = { content: RULE_BODY, globalPath: (ctx) => path.join(ctx.home, '.config', 'opencode', 'AGENTS.md'), - projectPath: (ctx) => path.join(ctx.cwd, 'AGENTS.md'), }, detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], }, @@ -320,9 +297,9 @@ function binaryOnPath(name: string, ctx: McpContext): boolean { } /** - * Detection prefers a false negative to a false positive: every agent is listed - * in the picker either way, so failing to pre-select one costs a keystroke, - * while pre-selecting an agent the user does not have is misleading. + * 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 therefore matched on its config directory alone. The name is also * used by an unrelated JavaScript engine that ships with common toolchains, so diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 4310565fb0..8d18bc1f5c 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -20,7 +20,6 @@ import { type McpClient, type McpClientId, type McpContext, - type McpScope, type McpTargetId, } from './mcp-clients'; @@ -243,20 +242,11 @@ export async function appendRuleSection( return 'installed'; } -function configPathFor(client: McpClient, scope: McpScope, ctx: McpContext) { - // Agents without project support always take the global path. - const projectPath = client.projectConfigPath?.(ctx); - return scope === 'project' && projectPath - ? projectPath - : client.globalConfigPath(ctx); -} - async function writeMcpEntry( client: McpClient, - scope: McpScope, ctx: McpContext ): Promise<{ status: 'configured' | 'reconfigured'; configPath: string }> { - const configPath = configPathFor(client, scope, ctx); + const configPath = client.globalConfigPath(ctx); const entry = client.buildEntry(ctx); if (client.format === 'toml') { @@ -288,15 +278,12 @@ async function writeMcpEntry( async function writeRule( client: McpClient, - scope: McpScope, ctx: McpContext ): Promise<{ status: 'installed' | 'updated' | 'unsupported'; path: string }> { const rule = client.rule; if (!rule) return { status: 'unsupported', path: '' }; - const projectPath = rule.projectPath?.(ctx); - const rulePath = - scope === 'project' && projectPath ? projectPath : rule.globalPath(ctx); + const rulePath = rule.globalPath(ctx); const status = rule.kind === 'file' ? await writeRuleFile(rulePath, rule.content) @@ -310,7 +297,7 @@ async function writeRule( */ export async function setupMcpClient( id: McpClientId, - options: { scope: McpScope; rules: boolean; ctx: McpContext } + options: { rules: boolean; ctx: McpContext } ): Promise { const client = MCP_CLIENTS[id]; const ctx = options.ctx; @@ -326,11 +313,7 @@ export async function setupMcpClient( }; try { - const { status, configPath } = await writeMcpEntry( - client, - options.scope, - ctx - ); + const { status, configPath } = await writeMcpEntry(client, ctx); result.mcpStatus = status; result.mcpDetail = configPath; } catch (error) { @@ -340,11 +323,7 @@ export async function setupMcpClient( if (!options.rules) return result; try { - const { status, path: rulePath } = await writeRule( - client, - options.scope, - ctx - ); + const { status, path: rulePath } = await writeRule(client, ctx); result.ruleStatus = status; result.ruleDetail = rulePath; } catch (error) { From f6d3d7e2b1df3b3ba6cfe7d299b7b9d1a7d21c52 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 16:48:19 -0700 Subject: [PATCH 08/35] fix(cli): one stored-key rule, accurate agent aliases, no repo config artifacts Three Firecrawl MCP configs were tracked at the repository root. Tests wrote them into the working directory and a broad `git add` swept them in, so anyone opening this checkout inherited MCP servers from the repo. Removed, and every setup test now runs in its own working directory so project-relative writes cannot reach the repository again. A stored key behaved three different ways depending on how the target was named: keyless for the boolean flags, keyless for `--agent hermes`, and a hard abort for `--agent all`. The README documents keyless. `--agent all` now agrees with the rest, and the launchers report through the same summary so the keyless fallback is stated rather than implied by a bare installer log line. `--agent launchers` was a synonym for every agent plus both launchers, which is not what the name says. It now selects the launchers. Launcher dispatch is exhaustive rather than treating anything that is not Hermes as OpenClaw, and doctor recognises OpenCode's top-level `mcp` map, which it previously reported as unregistered right after setup wrote it. --- .codex/config.toml | 2 -- .mcp.json | 8 ------ opencode.json | 9 ------ src/__tests__/commands/setup.test.ts | 41 ++++++++++++++++++++++------ src/commands/setup.ts | 40 +++++++++++++++++---------- src/utils/agents.ts | 4 +-- 6 files changed, 60 insertions(+), 44 deletions(-) delete mode 100644 .codex/config.toml delete mode 100644 .mcp.json delete mode 100644 opencode.json diff --git a/.codex/config.toml b/.codex/config.toml deleted file mode 100644 index 51bc8d23ee..0000000000 --- a/.codex/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[mcp_servers.firecrawl] -url = "https://mcp.firecrawl.dev/v2/mcp" diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index ba95cbffd3..0000000000 --- a/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "firecrawl": { - "type": "http", - "url": "https://mcp.firecrawl.dev/v2/mcp" - } - } -} diff --git a/opencode.json b/opencode.json deleted file mode 100644 index 79d0161acd..0000000000 --- a/opencode.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "mcp": { - "firecrawl": { - "type": "remote", - "url": "https://mcp.firecrawl.dev/v2/mcp", - "enabled": true - } - } -} diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index ffffc14c07..a377bdf3f2 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -62,6 +62,8 @@ describe('handleSetupCommand', () => { let originalPath: string | undefined; let originalUserProfile: string | undefined; let originalAppData: string | undefined; + let originalCwd: string; + let sandboxCwd: string; beforeEach(() => { vi.clearAllMocks(); @@ -83,12 +85,19 @@ describe('handleSetupCommand', () => { 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 = ''; }); 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; @@ -656,15 +665,29 @@ describe('handleSetupCommand', () => { ); }); - it('rejects stored credentials before configuring any launch integration', async () => { - await expect( - handleSetupCommand('mcp', { - agent: 'all', - global: true, - yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); - expect(execFileSync).not.toHaveBeenCalled(); + 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 }); + + expect(existsSync(path.join(sandboxHome, '.hermes', 'config.yaml'))).toBe( + true + ); + expect(existsSync(globalConfigPath('cursor', sandboxHome))).toBe(false); }); it('uses each client native environment binding with --agent all', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-env-test-')); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 669e4a8012..16c76f3b86 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -55,6 +55,7 @@ type SetupIntegration = SetupSubcommand; type ResolvedMcpAgent = | { kind: 'clients'; ids?: McpTargetId[] } + | { kind: 'launchers' } | { kind: 'skills-only'; agent: string } | { kind: 'hermes' } | { kind: 'openclaw' } @@ -259,9 +260,10 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { switch (normalized) { case '*': case 'all': + return { kind: 'all-launchers' }; case 'launchers': case 'launcher': - return { kind: 'all-launchers' }; + return { kind: 'launchers' }; case 'hermes': case 'hermes-agent': return { kind: 'hermes' }; @@ -565,18 +567,21 @@ export async function installMcp( return; } - if (resolvedAgent.kind === 'hermes') { - await installHermesMcp(runtimeEnv, keyless, Boolean(options.quiet)); + if (resolvedAgent.kind === 'hermes' || resolvedAgent.kind === 'openclaw') { + // Routed through the same reporter as every other target so the keyless + // fallback is stated rather than implied by a bare installer log line. + await installMcpClients({ ...options, yes: true }, runtimeEnv, [ + resolvedAgent.kind, + ]); return; } - if (resolvedAgent.kind === 'openclaw') { - await installOpenClawMcp(runtimeEnv, keyless, Boolean(options.quiet)); + if (resolvedAgent.kind === 'launchers') { + await installMcpClients({ ...options, yes: true }, runtimeEnv, [ + ...ALL_MCP_LAUNCHER_IDS, + ]); return; } if (resolvedAgent.kind === 'all-launchers') { - // Fails closed before touching anything: this path reaches launchers that - // hand the credential to a subprocess. - assertSubprocessSafeCredential(apiKey, runtimeEnv); await installMcpClients({ ...options, yes: true }, runtimeEnv, undefined, { includeAllLaunchers: true, }); @@ -636,12 +641,19 @@ async function setupMcpLauncher( }; try { - if (id === 'hermes') { - await installHermesMcp(runtimeEnv, keyless, true); - result.mcpDetail = path.join(ctx.home, '.hermes', 'config.yaml'); - } else { - await installOpenClawMcp(runtimeEnv, keyless, true); - result.mcpDetail = 'via the openclaw CLI'; + switch (id) { + case 'hermes': + await installHermesMcp(runtimeEnv, keyless, true); + result.mcpDetail = path.join(ctx.home, '.hermes', 'config.yaml'); + break; + case 'openclaw': + await installOpenClawMcp(runtimeEnv, keyless, true); + 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) { diff --git a/src/utils/agents.ts b/src/utils/agents.ts index 7ee913f076..1fbf8d41ee 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -168,9 +168,9 @@ async function fileHasFirecrawlMcp(filePath: string): Promise { /** * Keys under which agents store their MCP server map: `mcpServers` for Claude - * Code, Cursor, and Windsurf; `servers` for VS Code. + * Code, Cursor, and Windsurf; `servers` for VS Code; `mcp` for OpenCode. */ -const SERVER_MAP_KEYS = new Set(['mcpServers', 'servers']); +const SERVER_MAP_KEYS = new Set(['mcpServers', 'servers', 'mcp']); /** * Walk a parsed JSON config looking for a server map (or `mcp.servers`) that From aced17e4a2c9ad848ac44d416a88c3c4aa2f64da Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 12:43:55 -0700 Subject: [PATCH 09/35] fix(cli): close the remaining review findings on MCP setup Four defects, each reported against this branch and each confirmed against the code before changing it: * `resolveMcpClientId` read `__proto__` and `constructor` off the alias object's prototype and returned something truthy, so those two names resolved to a bogus agent and crashed the run instead of being rejected as unknown. `toString` and the rest are already rejected because lowercasing them stops matching an inherited key. * VS Code was detected only through `Code/User`, which is created on first launch. The picker offers detected agents alone, so an install that had not been launched was invisible rather than merely unselected. Detection now uses the same two markers doctor already uses. * The TOML editor scanned lines for table headers with no awareness of multi-line strings, so a `[table]` written inside one was treated as a real header and the surrounding edit could corrupt the file. The scan now tracks string state, and a multi-line string left open at the end of the file is reported as a per-agent failure instead of being appended to, which matches how the JSON path already treats a config it cannot parse. * `hasFirecrawlMcpEntry` matched `servers` and `mcp` at any depth, so an unrelated nested object holding a `firecrawl` property made doctor report the server as registered. Those two keys only ever sit at the root of the configs that are scanned; `mcpServers` keeps matching at any depth because Claude Code nests a per-project map under `projects`. Full TOML validation is deliberately not attempted. It would need a parser dependency, and a line-based validator would reject valid configs, since a TOML array may legally span several lines. --- src/__tests__/commands/doctor.test.ts | 8 +++ src/__tests__/utils/mcp-install.test.ts | 42 +++++++++++++ src/utils/agents.ts | 58 +++++++++-------- src/utils/mcp-clients.ts | 15 ++++- src/utils/mcp-install.ts | 82 ++++++++++++++++++++++--- 5 files changed, 170 insertions(+), 35 deletions(-) diff --git a/src/__tests__/commands/doctor.test.ts b/src/__tests__/commands/doctor.test.ts index a66a98adf8..506531e2f4 100644 --- a/src/__tests__/commands/doctor.test.ts +++ b/src/__tests__/commands/doctor.test.ts @@ -78,6 +78,14 @@ 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); + }); }); describe('runChecks', () => { diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 20f1b91609..21143c6aa3 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -231,6 +231,37 @@ describe('mcp install', () => { 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('refuses a config whose multi-line string is never closed', () => { + expect(() => + upsertTomlServer('instructions = """\nstill open\n', 'firecrawl', { + url: MCP_URL, + }) + ).toThrow('unterminated multi-line string'); + }); }); describe('appendRuleSection', () => { @@ -383,6 +414,12 @@ describe('mcp install', () => { 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('resolveMcpClientId', () => { @@ -392,5 +429,10 @@ describe('mcp install', () => { 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/utils/agents.ts b/src/utils/agents.ts index 1fbf8d41ee..499c1ca455 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -166,43 +166,49 @@ async function fileHasFirecrawlMcp(filePath: string): Promise { } } +/** 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') + ); +} + /** - * Keys under which agents store their MCP server map: `mcpServers` for Claude - * Code, Cursor, and Windsurf; `servers` for VS Code; `mcp` for OpenCode. + * Claude Code keeps a per-project server map under `projects`, so `mcpServers` + * is the one key that has to be matched at any depth. */ -const SERVER_MAP_KEYS = new Set(['mcpServers', 'servers', 'mcp']); +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 (or `mcp.servers`) that - * contains a `firecrawl` key. Exported for testing. + * 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 (SERVER_MAP_KEYS.has(key) && 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); } /** diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 36328c3a5b..8d9ed320b9 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -200,7 +200,12 @@ export const MCP_CLIENTS: Record = { globalPath: (ctx) => path.join(vscodeUserDir(ctx), 'prompts', 'firecrawl.instructions.md'), }, - detectPaths: (ctx) => [vscodeUserDir(ctx)], + // `User` is created on first launch, so requiring it misses an install + // that has only been unpacked. These are the markers doctor already uses. + detectPaths: (ctx) => [ + appSupportDir(ctx, 'Code'), + path.join(ctx.home, '.vscode'), + ], }, codex: { id: 'codex', @@ -335,7 +340,13 @@ const CLIENT_ALIASES: Record = { }; export function resolveMcpClientId(agent: string): McpClientId | undefined { - return CLIENT_ALIASES[agent.trim().toLowerCase()]; + 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 { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 8d18bc1f5c..d6891213b2 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -130,6 +130,60 @@ export async function writeJsonServerEntry( return { status: alreadyExists ? 'reconfigured' : 'configured' }; } +/** Advance past a single-line basic or literal string, escapes included. */ +function skipQuoted(line: string, start: number, quote: string): number { + let index = start + 1; + while (index < line.length) { + // Only basic strings honour backslash escapes; literal strings have none. + if (quote === '"' && line[index] === '\\') { + index += 2; + continue; + } + if (line[index] === quote) return index + 1; + index += 1; + } + return line.length; +} + +/** + * Mark the lines that begin outside a multi-line string, so a `[table]` written + * inside one is not mistaken for a real table header. Throws when a multi-line + * string is still open at the end, which is malformed TOML: editing a file this + * scan cannot follow would corrupt it while reporting success. + */ +function linesOutsideStrings(lines: string[]): boolean[] { + const outside: boolean[] = []; + let fence: '"""' | "'''" | null = null; + + for (const line of lines) { + outside.push(fence === null); + let index = 0; + while (index < line.length) { + if (fence) { + const close = line.indexOf(fence, index); + if (close === -1) break; + index = close + fence.length; + fence = null; + continue; + } + if (line[index] === '#') break; + if (line.startsWith('"""', index) || line.startsWith("'''", index)) { + fence = line[index] === '"' ? '"""' : "'''"; + index += 3; + continue; + } + if (line[index] === '"' || line[index] === "'") { + index = skipQuoted(line, index, line[index]); + continue; + } + index += 1; + } + } + + if (fence) throw new Error('unterminated multi-line string'); + return outside; +} + /** * Insert or replace the `[mcp_servers.]` table. Any sub-tables of that * server are consumed too, so a leftover `[mcp_servers.firecrawl.env]` from an @@ -158,8 +212,11 @@ export function upsertTomlServer( `^[ \\t]*\\[mcp_servers\\.${escaped}(\\.[^\\]]+)?\\][ \\t]*(?:#.*)?$` ); const anyTable = /^[ \t]*\[/; + const outside = linesOutsideStrings(lines); - const start = lines.findIndex((line) => ownTable.test(line)); + const start = lines.findIndex( + (line, index) => outside[index] && ownTable.test(line) + ); if (start === -1) { // Tables must follow root-level keys, so append at the end of the file. @@ -176,7 +233,13 @@ export function upsertTomlServer( let end = start + 1; while (end < lines.length) { - if (anyTable.test(lines[end]) && !ownTable.test(lines[end])) break; + if ( + outside[end] && + anyTable.test(lines[end]) && + !ownTable.test(lines[end]) + ) { + break; + } end += 1; } // Comments and blank lines directly above the next table introduce it, so @@ -255,11 +318,16 @@ async function writeMcpEntry( for (const [key, value] of Object.entries(entry)) { if (typeof value === 'string') stringEntry[key] = value; } - const { content, alreadyExists } = upsertTomlServer( - existing, - MCP_SERVER_NAME, - stringEntry - ); + 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', From 181768abb460ce9336427c37bbbb71d991c0f60b Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 12:49:12 -0700 Subject: [PATCH 10/35] fix(cli): keep an escaped quote from ending a TOML multi-line string The scanner added in the previous commit closed a multi-line basic string at the first `"""` it found, but a basic string honours backslash escapes, so `\"""` is an escaped quote followed by two literal ones rather than the terminator. Ending the string there marked the rest of it as ordinary config, which is how a `[table]` written inside a string becomes a header the editor will replace, taking the user's content with it. Fence candidates are now skipped while the backslash run before them is odd. An even run is a real terminator, since backslashes escape each other. Literal strings are unaffected because they have no escapes at all. --- src/__tests__/utils/mcp-install.test.ts | 47 +++++++++++++++++++++++++ src/utils/mcp-install.ts | 20 ++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 21143c6aa3..baf0a40bb9 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -255,6 +255,53 @@ describe('mcp install', () => { ); }); + 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', { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index d6891213b2..34656496b3 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -130,6 +130,18 @@ export async function writeJsonServerEntry( return { status: alreadyExists ? 'reconfigured' : 'configured' }; } +/** + * True when the character at `index` is escaped. Backslashes escape each other, + * so only an odd run of them before the position leaves it escaped. + */ +function isEscaped(line: string, index: number): boolean { + let backslashes = 0; + for (let at = index - 1; at >= 0 && line[at] === '\\'; at -= 1) { + backslashes += 1; + } + return backslashes % 2 === 1; +} + /** Advance past a single-line basic or literal string, escapes included. */ function skipQuoted(line: string, start: number, quote: string): number { let index = start + 1; @@ -160,7 +172,13 @@ function linesOutsideStrings(lines: string[]): boolean[] { let index = 0; while (index < line.length) { if (fence) { - const close = line.indexOf(fence, index); + let close = line.indexOf(fence, index); + // A basic string honours escapes, so `\"""` is an escaped quote + // followed by two literal ones rather than the terminator. Literal + // strings have no escapes, so their fence always closes. + while (close !== -1 && fence === '"""' && isEscaped(line, close)) { + close = line.indexOf(fence, close + 1); + } if (close === -1) break; index = close + fence.length; fence = null; From 5e74423b18a9bac9f6577965cbb0e9196d6ca1a3 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 13:16:54 -0700 Subject: [PATCH 11/35] feat(cli): configure Hermes Agent through the shared MCP engine Hermes was classified as a launcher, which implied it owns its MCP config and has to be shelled out to. It does not: it reads plain YAML from ~/.hermes/config.yaml under `mcp_servers`, with `url` and a `headers` mapping for an HTTP server, and it expands `${VAR}` in any string value in a server entry. All of that is documented by Nous Research and matches what we already emit, so Hermes is a config-file client and now goes through the same engine as the editors. That fixes a real defect. The old writer round-tripped the file through parse/stringify, so a hand-written config.yaml came back with every comment, inline note, and blank line removed. Edits now go through the YAML document tree, which keeps comments, key order, and formatting, and a file that does not parse is reported as a per-agent failure instead of being rewritten. A config we create is owner-only; one the user already has keeps the permissions they gave it, rather than being chmod-ed on every run. Hermes gets no rules. It reads AGENTS.md from the project directory and setup only ever writes global config, so there is no global rule file to own, and the summary says so rather than implying one was written. OpenClaw stays a launcher, and the type now says why. Its config is JSON5, which the JSONC editor we patch JSON with cannot read, so writing that file directly would either corrupt it or refuse a valid config. `openclaw mcp set` is the vendor-documented path and normalises the entry on the way in. --- src/__tests__/commands/setup.test.ts | 33 +++++++++--- src/__tests__/utils/mcp-install.test.ts | 69 +++++++++++++++++++++++++ src/commands/setup.ts | 52 +------------------ src/utils/mcp-clients.ts | 54 ++++++++++++++----- src/utils/mcp-install.ts | 52 ++++++++++++++++++- 5 files changed, 190 insertions(+), 70 deletions(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index a377bdf3f2..b43df162c2 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -15,7 +15,6 @@ import { handleMakeDefaultCommand, handleSetupCommand, installMcp, - installHermesMcp, installOpenClawMcp, installSkillsForAgent, } from '../../commands/setup'; @@ -449,7 +448,7 @@ describe('handleSetupCommand', () => { }); it('detects an installed launcher so the picker can pre-select it', async () => { - mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + mkdirSync(path.join(sandboxHome, '.openclaw'), { recursive: true }); const { detectMcpLaunchers } = await import('../../utils/mcp-clients'); expect( @@ -460,7 +459,26 @@ describe('handleSetupCommand', () => { env: { PATH: '' }, auth: 'keyless', }) - ).toContain('hermes'); + ).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 () => { @@ -573,7 +591,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'), @@ -684,10 +702,13 @@ describe('handleSetupCommand', () => { 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( - true + false ); - expect(existsSync(globalConfigPath('cursor', sandboxHome))).toBe(false); }); it('uses each client native environment binding with --agent all', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-env-test-')); diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index baf0a40bb9..70064ff87a 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -13,10 +13,12 @@ import { resolveMcpClientId, type McpContext, } from '../../utils/mcp-clients'; +import { parse as parseYaml } from 'yaml'; import { appendRuleSection, setupMcpClient, upsertTomlServer, + upsertYamlServer, writeJsonServerEntry, } from '../../utils/mcp-install'; @@ -311,6 +313,73 @@ describe('mcp install', () => { }); }); + 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('refuses a config that does not parse', () => { + expect(() => + upsertYamlServer( + 'model: "unterminated\nother: 1\n', + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ) + ).toThrow(/quote/i); + }); + }); + describe('appendRuleSection', () => { it('keeps existing content and replaces only the fenced section', async () => { const file = path.join(root, 'AGENTS.md'); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 16c76f3b86..bcd3756016 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -4,17 +4,10 @@ */ import { execFileSync, execSync } from 'child_process'; -import { - chmodSync, - existsSync, - mkdirSync, - readFileSync, - writeFileSync, -} from 'fs'; +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, @@ -57,7 +50,6 @@ type ResolvedMcpAgent = | { kind: 'clients'; ids?: McpTargetId[] } | { kind: 'launchers' } | { kind: 'skills-only'; agent: string } - | { kind: 'hermes' } | { kind: 'openclaw' } | { kind: 'all-launchers' }; @@ -264,9 +256,6 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { case 'launchers': case 'launcher': return { kind: 'launchers' }; - case 'hermes': - case 'hermes-agent': - return { kind: 'hermes' }; case 'openclaw': return { kind: 'openclaw' }; default: { @@ -567,7 +556,7 @@ export async function installMcp( return; } - if (resolvedAgent.kind === 'hermes' || resolvedAgent.kind === 'openclaw') { + if (resolvedAgent.kind === 'openclaw') { // Routed through the same reporter as every other target so the keyless // fallback is stated rather than implied by a bare installer log line. await installMcpClients({ ...options, yes: true }, runtimeEnv, [ @@ -642,10 +631,6 @@ async function setupMcpLauncher( try { switch (id) { - case 'hermes': - await installHermesMcp(runtimeEnv, keyless, true); - result.mcpDetail = path.join(ctx.home, '.hermes', 'config.yaml'); - break; case 'openclaw': await installOpenClawMcp(runtimeEnv, keyless, true); result.mcpDetail = 'via the openclaw CLI'; @@ -857,39 +842,6 @@ function firecrawlMcpConfig( }; } -export async function installHermesMcp( - runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false, - /** Suppress standalone logging when a caller renders its own summary. */ - quiet = 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); - } - if (!quiet) console.log(`Hermes Agent MCP configured at ${configPath}.`); -} - export async function installOpenClawMcp( runtimeEnv: NodeJS.ProcessEnv = process.env, keyless = false, diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 8d9ed320b9..523096a55e 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -19,13 +19,23 @@ export const FIRECRAWL_MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; export const MCP_SERVER_NAME = 'firecrawl'; export const API_KEY_ENV_VAR = 'FIRECRAWL_API_KEY'; -export type McpClientId = 'claude' | 'cursor' | 'vscode' | 'codex' | 'opencode'; +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 = 'hermes' | 'openclaw'; +export type McpLauncherId = 'openclaw'; export type McpTargetId = McpClientId | McpLauncherId; @@ -58,10 +68,15 @@ export interface McpRuleSpec { export interface McpClient { id: McpClientId; name: string; - format: 'json' | 'toml'; + 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 the agent has no rules mechanism. */ rule?: McpRuleSpec; @@ -247,6 +262,23 @@ export const MCP_CLIENTS: Record = { }, detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], }, + hermes: { + id: 'hermes', + name: 'Hermes Agent', + format: 'yaml', + serversKey: 'mcp_servers', + globalConfigPath: (ctx) => path.join(ctx.home, '.hermes', '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: FIRECRAWL_MCP_URL }, 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. + detectPaths: (ctx) => [path.join(ctx.home, '.hermes')], + }, }; export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ @@ -255,17 +287,14 @@ export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ 'vscode', 'codex', 'opencode', + 'hermes', ]; export const MCP_LAUNCHER_NAMES: Record = { - hermes: 'Hermes Agent', openclaw: 'OpenClaw', }; -export const ALL_MCP_LAUNCHER_IDS: readonly McpLauncherId[] = [ - 'hermes', - 'openclaw', -]; +export const ALL_MCP_LAUNCHER_IDS: readonly McpLauncherId[] = ['openclaw']; export const ALL_MCP_TARGET_IDS: readonly McpTargetId[] = [ ...ALL_MCP_CLIENT_IDS, @@ -306,12 +335,11 @@ function binaryOnPath(name: string, ctx: McpContext): boolean { * 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 therefore matched on its config directory alone. The name is also - * used by an unrelated JavaScript engine that ships with common toolchains, so - * a PATH lookup reports it present on machines that do not have this agent. + * 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> = { - hermes: (ctx) => existsSync(path.join(ctx.home, '.hermes')), openclaw: (ctx) => existsSync(path.join(ctx.home, '.openclaw')) || binaryOnPath('openclaw', ctx), @@ -337,6 +365,8 @@ const CLIENT_ALIASES: Record = { 'codex-gui': 'codex', opencode: 'opencode', 'open-code': 'opencode', + hermes: 'hermes', + 'hermes-agent': 'hermes', }; export function resolveMcpClientId(agent: string): McpClientId | undefined { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 34656496b3..64447e0dc3 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -12,6 +12,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser'; +import { parseDocument } from 'yaml'; import { MCP_CLIENTS, MCP_SERVER_NAME, @@ -59,10 +60,12 @@ async function readIfExists(filePath: string): Promise { async function writeFileEnsuringDir( filePath: string, - content: 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, 'utf8'); + await fs.writeFile(filePath, content, { encoding: 'utf8', mode: createMode }); } function escapeRegExp(value: string): string { @@ -130,6 +133,28 @@ export async function writeJsonServerEntry( 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]); + doc.setIn([serversKey, serverName], entry); + return { content: doc.toString(), alreadyExists }; +} + /** * True when the character at `index` is escaped. Backslashes escape each other, * so only an odd run of them before the position leaves it escaped. @@ -330,6 +355,29 @@ async function writeMcpEntry( const configPath = client.globalConfigPath(ctx); const 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 = {}; From ff479954d76d82cd8bb771f0a80e15dfd2310c10 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 14:05:02 -0700 Subject: [PATCH 12/35] fix(cli): preserve YAML file details the document tree drops Two defects in the new Hermes writer, both reported against the last commit: * A server key with nothing under it, `mcp_servers:` on its own, parses as a null scalar. Setting a path through that refuses to descend, so setup failed on a valid config. The key has to be replaced with a collection node first; assigning a plain object raises the same error one level down, because the value is stored as-is rather than converted. * Serialising the document tree drops a leading byte order mark and rewrites every line with LF. Both belong to the user's file, so they are captured from the input and restored on write, which is how the JSON and TOML writers already treat them. --- src/__tests__/utils/mcp-install.test.ts | 35 +++++++++++++++++++++++++ src/utils/mcp-install.ts | 18 ++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 70064ff87a..83e8402271 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -368,6 +368,41 @@ describe('mcp install', () => { 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 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( diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 64447e0dc3..0f93482953 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -151,8 +151,24 @@ export function upsertYamlServer( } const alreadyExists = doc.hasIn([serversKey, serverName]); + // 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. + if (doc.getIn([serversKey]) === null) { + doc.setIn([serversKey], doc.createNode({})); + } doc.setIn([serversKey, serverName], entry); - return { content: doc.toString(), alreadyExists }; + + // 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, + }; } /** From d62a3e861494eeac2b49df7b319fc02f9424b87d Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 14:24:14 -0700 Subject: [PATCH 13/35] feat(cli): install the Firecrawl rule for OpenClaw OpenClaw was reported as having no rules mechanism. It has one: the workspace AGENTS.md is a bootstrap file that OpenClaw injects into the system prompt on every turn, treats as instruction context, and passes down to sub-agent sessions. It is workspace-level rather than project-level, so it is reachable from a global setup, and the rule now goes there fenced by markers like any file the user also writes to. The workspace can move, so the path follows OPENCLAW_WORKSPACE_DIR and the profile suffix before falling back to ~/.openclaw/workspace. An explicit agents.defaults.workspace in the config wins over both, but that file is JSON5 and out of reach here; landing the rule in an unused workspace is inert, unlike a misplaced server entry. The rule is only written when that AGENTS.md already exists. OpenClaw seeds the file with its own instructions on first run, and creating it first would leave the user with our section instead of those. Writing a rule and registering the server are separate concerns, so a launcher can take one without giving up its own MCP registration. Hermes still gets no rule: it reads AGENTS.md from the working directory and deliberately ignores one in $HOME, and its only global context file is SOUL.md, which is the user's agent identity rather than a place for tool routing. --- src/__tests__/commands/setup.test.ts | 69 +++++++++++++++++++++++++++- src/commands/setup.ts | 33 +++++++++++-- src/utils/mcp-clients.ts | 27 +++++++++++ 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index b43df162c2..df273f7a4c 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -21,7 +21,11 @@ import { import { ALL_SKILL_REPOS } from '../../commands/skills-install'; import { configureWebDefaults } from '../../utils/web-defaults'; import { getApiKey } from '../../utils/config'; -import { MCP_CLIENTS, type McpClientId } from '../../utils/mcp-clients'; +import { + MCP_CLIENTS, + RULE_MARKER, + type McpClientId, +} from '../../utils/mcp-clients'; const MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; @@ -710,6 +714,69 @@ describe('handleSetupCommand', () => { 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('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', { + clients: ['openclaw'], + yes: true, + rules: true, + } as never); + + expect(readFileSync(path.join(moved, 'AGENTS.md'), 'utf-8')).toContain( + 'firecrawl_search' + ); + } finally { + delete process.env.OPENCLAW_WORKSPACE_DIR; + } + }); + 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. diff --git a/src/commands/setup.ts b/src/commands/setup.ts index bcd3756016..86d0fddf45 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -33,6 +33,7 @@ import { detectMcpClients, detectMcpLaunchers, isMcpLauncherId, + MCP_LAUNCHER_RULES, mcpTargetName, resolveMcpClientId, type McpAuthMode, @@ -40,7 +41,11 @@ import { type McpLauncherId, type McpTargetId, } from '../utils/mcp-clients'; -import { setupMcpClient, type McpClientResult } from '../utils/mcp-install'; +import { + appendRuleSection, + setupMcpClient, + type McpClientResult, +} from '../utils/mcp-install'; export type SetupSubcommand = 'skills' | 'workflows' | 'mcp' | 'defaults'; @@ -616,7 +621,8 @@ async function pickMcpClients( async function setupMcpLauncher( id: McpLauncherId, ctx: McpContext, - runtimeEnv: NodeJS.ProcessEnv + runtimeEnv: NodeJS.ProcessEnv, + rules: boolean ): Promise { const keyless = ctx.auth !== 'env'; const result: McpClientResult = { @@ -644,6 +650,27 @@ async function setupMcpLauncher( } catch (error) { result.mcpDetail = error instanceof Error ? error.message : String(error); } + + const rule = MCP_LAUNCHER_RULES[id]; + if (!rules || !rule) return result; + + const rulePath = 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; + } + + try { + result.ruleStatus = await appendRuleSection(rulePath, rule.content); + result.ruleDetail = rulePath; + } catch (error) { + result.ruleStatus = 'failed'; + result.ruleDetail = error instanceof Error ? error.message : String(error); + } return result; } @@ -722,7 +749,7 @@ async function installMcpClients( for (const id of selected) { results.push( isMcpLauncherId(id) - ? await setupMcpLauncher(id, ctx, runtimeEnv) + ? await setupMcpLauncher(id, ctx, runtimeEnv, rules) : await setupMcpClient(id, { rules, ctx }) ); } diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 523096a55e..2fc89e3e09 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -294,6 +294,33 @@ 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. + */ +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[] = [ From d565a7f67206c97c3679d98626212517e45107c6 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 16:39:56 -0700 Subject: [PATCH 14/35] feat(cli): add the browser sign-in lane to setup mcp Setup could carry a key or run anonymously, but not sign in, so the one path the docs present to a person at a terminal had no command behind it. `--oauth` writes the sign-in endpoint instead of a credential, and each agent starts the browser flow itself the first time it connects. Sign-in is a different server URL rather than a different header, so it replaces the credential rather than travelling beside it: an exported key is ignored under --oauth and no Authorization header is written. The two are separate endpoints, so combining --oauth with --keyless is rejected instead of silently preferring one. A URL alone is not enough for every agent. Hermes starts the flow only when the entry carries `auth: oauth`, and OpenClaw ignores a static Authorization header unless `auth: "oauth"` is set, which is also what gates its login command. Those fields live beside the agent in the registry, next to the environment syntax, and an agent without a verified sign-in shape gets no entry rather than one that reports success and then exposes no tools. No agent signs in during setup, and each one starts the flow differently, so the summary prints the step per agent rather than one footer: `/mcp` in Claude Code, `codex mcp login firecrawl`, Cursor Settings, and a browser on first use for the rest. --- README.md | 12 +++++ src/__tests__/commands/setup.test.ts | 57 ++++++++++++++++++++++ src/commands/setup.ts | 73 +++++++++++++++++++++++----- src/index.ts | 2 + src/utils/mcp-clients.ts | 66 +++++++++++++++++++++---- src/utils/mcp-install.ts | 6 ++- 6 files changed, 193 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3e9b44da36..6c4147f4ca 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,18 @@ 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. +To sign in from the agent instead of carrying a key, use `--oauth`: + +```bash +firecrawl setup mcp --oauth # sign in from each agent's browser +``` + +This writes the sign-in endpoint rather than a credential, and each agent starts +the browser flow itself the first time it connects. Setup prints the step each +agent needs, since they differ: `/mcp` in Claude Code, `codex mcp login +firecrawl` for Codex, Cursor Settings, and a browser on first use elsewhere. +`--oauth` and `--keyless` are different endpoints, so pass only one. + To make Firecrawl the default web provider for supported AI agents: ```bash diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index df273f7a4c..724a0b798b 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -777,6 +777,63 @@ describe('handleSetupCommand', () => { } }); + 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('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. diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 86d0fddf45..e112b54d77 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -32,7 +32,10 @@ import { ALL_MCP_TARGET_IDS, detectMcpClients, detectMcpLaunchers, + FIRECRAWL_MCP_OAUTH_URL, isMcpLauncherId, + MCP_CLIENTS, + MCP_LAUNCHER_OAUTH, MCP_LAUNCHER_RULES, mcpTargetName, resolveMcpClientId, @@ -70,6 +73,8 @@ 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. */ @@ -194,8 +199,8 @@ function runClientCommand( 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( @@ -638,7 +643,12 @@ async function setupMcpLauncher( try { switch (id) { case 'openclaw': - await installOpenClawMcp(runtimeEnv, keyless, true); + await installOpenClawMcp( + runtimeEnv, + keyless, + true, + ctx.auth === 'oauth' + ); result.mcpDetail = 'via the openclaw CLI'; break; default: { @@ -689,12 +699,22 @@ async function installMcpClients( explicitIds?: McpTargetId[], { includeAllLaunchers = false } = {} ): Promise { - const apiKey = options.keyless ? undefined : getApiKey(); - // 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 = isEnvironmentBackedApiKey(apiKey, runtimeEnv) - ? 'env' - : 'keyless'; + if (options.oauth && options.keyless) { + throw new Error( + 'Choose either --oauth or --keyless. Signing in and running anonymously are different endpoints.' + ); + } + + 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. @@ -786,6 +806,12 @@ function authNotes( const succeeded = results.filter((result) => result.mcpStatus !== 'failed'); if (succeeded.length === 0) return []; + if (ctx.auth === 'oauth') { + return [ + 'Each agent signs in through your browser the first time it connects.', + ]; + } + if (!hasApiKey) { return [ `Running keyless (search, scrape, parse). Export ${ENV_API_KEY} where your agents run, then rerun to authenticate.`, @@ -801,6 +827,22 @@ function authNotes( return []; } +/** + * What the person still has to do for this agent. Setup can register the + * server but no agent signs in on its behalf, and each one starts the flow + * differently, so a single footer would leave most agents unexplained. + */ +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 ? ` Sign in ${dim}${spec.nextStep}${reset}` : undefined; +} + function reportMcpResults( results: McpClientResult[], ctx: McpContext, @@ -833,6 +875,8 @@ function reportMcpResults( ? ` ${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); } @@ -853,14 +897,15 @@ function reportMcpResults( 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), headers: firecrawlMcpHeaders( agent, keyless ? undefined : getApiKey(), @@ -873,11 +918,13 @@ export async function installOpenClawMcp( runtimeEnv: NodeJS.ProcessEnv = process.env, keyless = false, /** Suppress standalone logging when a caller renders its own summary. */ - quiet = false + 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), }; if (!quiet) console.log('Configuring Firecrawl MCP for OpenClaw...\n'); diff --git a/src/index.ts b/src/index.ts index 2819ffdf77..aa8ff14fc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2254,6 +2254,7 @@ const setupCommand = 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' @@ -2278,6 +2279,7 @@ setupCommand ` 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 diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 2fc89e3e09..13b45f7a7d 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -16,6 +16,11 @@ import { existsSync, promises as fs } from 'fs'; 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'; @@ -42,9 +47,11 @@ 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. + * `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'; +export type McpAuthMode = 'env' | 'keyless' | 'oauth'; export interface McpContext { home: string; @@ -65,6 +72,18 @@ export interface McpRuleSpec { globalPath: (ctx: McpContext) => string; } +/** + * 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; + /** What the person does next, since no agent signs in during setup. */ + nextStep: string; +} + export interface McpClient { id: McpClientId; name: string; @@ -78,6 +97,8 @@ export interface McpClient { */ 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. */ @@ -150,6 +171,11 @@ 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, @@ -170,7 +196,7 @@ export const MCP_CLIENTS: Record = { buildEntry: (ctx) => withEnvAuth( ctx, - { type: 'http', url: FIRECRAWL_MCP_URL }, + { type: 'http', url: firecrawlMcpUrl(ctx) }, ENV_HEADER.shell ), rule: { @@ -179,6 +205,7 @@ export const MCP_CLIENTS: Record = { globalPath: (ctx) => path.join(claudeConfigDir(ctx), 'rules', 'firecrawl.md'), }, + oauth: { nextStep: 'run /mcp in Claude Code to sign in' }, detectPaths: (ctx) => [claudeConfigDir(ctx), claudeGlobalConfigPath(ctx)], }, cursor: { @@ -188,13 +215,14 @@ export const MCP_CLIENTS: Record = { serversKey: 'mcpServers', globalConfigPath: (ctx) => path.join(ctx.home, '.cursor', 'mcp.json'), buildEntry: (ctx) => - withEnvAuth(ctx, { url: FIRECRAWL_MCP_URL }, ENV_HEADER.editor), + withEnvAuth(ctx, { url: firecrawlMcpUrl(ctx) }, ENV_HEADER.editor), rule: { kind: 'file', content: CURSOR_RULE, globalPath: (ctx) => path.join(ctx.home, '.cursor', 'rules', 'firecrawl.mdc'), }, + oauth: { nextStep: 'open Cursor Settings, select MCP, and sign in' }, detectPaths: (ctx) => [path.join(ctx.home, '.cursor')], }, vscode: { @@ -206,7 +234,7 @@ export const MCP_CLIENTS: Record = { buildEntry: (ctx) => withEnvAuth( ctx, - { type: 'http', url: FIRECRAWL_MCP_URL }, + { type: 'http', url: firecrawlMcpUrl(ctx) }, ENV_HEADER.editor ), rule: { @@ -217,6 +245,7 @@ export const MCP_CLIENTS: Record = { }, // `User` is created on first launch, so requiring it misses an install // that has only been unpacked. These are the markers doctor already uses. + oauth: { nextStep: 'sign in from the MCP view in VS Code' }, detectPaths: (ctx) => [ appSupportDir(ctx, 'Code'), path.join(ctx.home, '.vscode'), @@ -232,13 +261,15 @@ export const MCP_CLIENTS: Record = { // so it authenticates without a header template. buildEntry: (ctx) => ctx.auth === 'env' - ? { url: FIRECRAWL_MCP_URL, bearer_token_env_var: API_KEY_ENV_VAR } - : { url: FIRECRAWL_MCP_URL }, + ? { url: firecrawlMcpUrl(ctx), bearer_token_env_var: API_KEY_ENV_VAR } + : { url: firecrawlMcpUrl(ctx) }, rule: { kind: 'append', content: RULE_BODY, globalPath: (ctx) => path.join(ctx.home, '.codex', 'AGENTS.md'), }, + // Codex registers the server but does not start the flow on its own. + oauth: { nextStep: 'run codex mcp login firecrawl' }, detectPaths: (ctx) => [path.join(ctx.home, '.codex')], }, opencode: { @@ -251,7 +282,7 @@ export const MCP_CLIENTS: Record = { buildEntry: (ctx) => withEnvAuth( ctx, - { type: 'remote', url: FIRECRAWL_MCP_URL, enabled: true }, + { type: 'remote', url: firecrawlMcpUrl(ctx), enabled: true }, ENV_HEADER.brace ), rule: { @@ -260,6 +291,7 @@ export const MCP_CLIENTS: Record = { globalPath: (ctx) => path.join(ctx.home, '.config', 'opencode', 'AGENTS.md'), }, + oauth: { nextStep: 'OpenCode opens the browser on first use' }, detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], }, hermes: { @@ -274,9 +306,14 @@ export const MCP_CLIENTS: Record = { // 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: FIRECRAWL_MCP_URL }, ENV_HEADER.shell), + 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. + oauth: { + entry: { auth: 'oauth' }, + nextStep: 'Hermes opens the browser on first use', + }, detectPaths: (ctx) => [path.join(ctx.home, '.hermes')], }, }; @@ -313,6 +350,17 @@ function openclawWorkspaceDir(ctx: McpContext): string { * 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: 'run openclaw mcp login firecrawl', + }, + }; + export const MCP_LAUNCHER_RULES: Partial> = { openclaw: { kind: 'append', diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 0f93482953..456a560e1b 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -369,7 +369,11 @@ async function writeMcpEntry( ctx: McpContext ): Promise<{ status: 'configured' | 'reconfigured'; configPath: string }> { const configPath = client.globalConfigPath(ctx); - const entry = client.buildEntry(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)) ?? ''; From ada3954038b03eed6b687cddf23d6d61d1b98757 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 16:43:47 -0700 Subject: [PATCH 15/35] fix(cli): keep user content and report rules accurately Four defects reported against the sign-in commit, each confirmed against the code before changing it: * Replacing an empty `mcp_servers:` key dropped an inline comment sitting on it. The comment belongs to the null value being replaced; a block map has no inline slot on its key, so it now moves to the head of the section instead of disappearing with the node it was attached to. Preserved and relocated beats silently deleted. * A marker-fenced rule section was written with LF regardless of the file it joined, so updating a CRLF AGENTS.md left mixed endings. The section now adopts the line endings of the file it is written into, which is how the JSON, TOML, and YAML writers already behave. * The OpenClaw workspace was resolved from the environment and the documented defaults alone, so a workspace moved through config took the rule to a path the launcher never reads. OpenClaw is now asked where its workspace is, since its config is JSON5 and out of reach; the previous resolution stays as the fallback for when the CLI cannot answer. * Declining rules reported OpenClaw as not supporting them, which stopped being true when it gained a rule. It reports skipped now, and unsupported is left for agents that genuinely have nowhere to put one. --- src/__tests__/utils/mcp-install.test.ts | 27 ++++++++++++++++ src/commands/setup.ts | 42 +++++++++++++++++++++++-- src/utils/mcp-install.ts | 20 +++++++++--- 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 83e8402271..535ae5a002 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -383,6 +383,21 @@ describe('mcp install', () => { }); }); + 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'; @@ -446,6 +461,18 @@ describe('mcp install', () => { }); }); + 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', { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index e112b54d77..8c4eb6e35a 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -618,6 +618,38 @@ async function pickMcpClients( }); } +/** + * 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( + runtimeEnv: NodeJS.ProcessEnv, + id: McpLauncherId +): string | undefined { + if (id !== 'openclaw') return undefined; + try { + const stdout = execFileSync( + 'openclaw', + ['config', 'get', 'agents.defaults.workspace', '--json'], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + env: cleanNpmEnv(), + } + ); + const value: unknown = JSON.parse(stdout); + if (typeof value !== 'string' || value === '') return undefined; + const expanded = value.startsWith('~') + ? path.join(os.homedir(), value.slice(1)) + : value; + return path.join(expanded, 'AGENTS.md'); + } catch { + return undefined; + } +} + /** * Launchers own their MCP configuration, so they are installed through their * own routine instead of a config write. Failures stay scoped to the one @@ -662,9 +694,15 @@ async function setupMcpLauncher( } const rule = MCP_LAUNCHER_RULES[id]; - if (!rules || !rule) return result; + if (!rule) return result; + if (!rules) { + // The launcher does take rules; the run just did not ask for them. + result.ruleStatus = 'skipped'; + return result; + } - const rulePath = rule.globalPath(ctx); + const rulePath = + openclawConfiguredWorkspace(runtimeEnv, 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. diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 456a560e1b..cb58423697 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -156,7 +156,13 @@ export function upsertYamlServer( // assigning a plain object leaves the same error one level down. An absent // key needs none of this, since setIn creates the path itself. if (doc.getIn([serversKey]) === null) { - doc.setIn([serversKey], doc.createNode({})); + const empty = doc.getIn([serversKey], true) 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); } doc.setIn([serversKey, serverName], entry); @@ -343,8 +349,11 @@ export async function appendRuleSection( filePath: string, content: string ): Promise<'installed' | 'updated'> { - const section = `${RULE_MARKER}\n${content}${RULE_MARKER}`; 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}`; const marker = escapeRegExp(RULE_MARKER); const fenced = new RegExp(`${marker}\\r?\\n[\\s\\S]*?${marker}`); @@ -359,8 +368,11 @@ export async function appendRuleSection( } const separator = - existing.length === 0 ? '' : existing.endsWith('\n') ? '\n' : '\n\n'; - await writeFileEnsuringDir(filePath, `${existing}${separator}${section}\n`); + existing.length === 0 ? '' : existing.endsWith('\n') ? eol : `${eol}${eol}`; + await writeFileEnsuringDir( + filePath, + `${existing}${separator}${section}${eol}` + ); return 'installed'; } From 22a9e3988f066295f381e4c08987b1942eadb88b Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 16:58:59 -0700 Subject: [PATCH 16/35] fix(cli): keep credentials off the sign-in endpoint installOpenClawMcp read the stored key whenever keyless was not also set, so calling it with sign-in alone produced an OAuth entry carrying an Authorization header. OpenClaw ignores a static header once auth is oauth, so the result was inert rather than harmful, but writing credential configuration into a sign-in entry is wrong either way. The CLI never reached this: setup derives keyless from the auth mode, and sign-in is not env, so the header was already dropped. The helper is exported though, and the credential helper beside it is written to be safe in isolation for the same reason, so sign-in now drops the key in the config builder where every caller passes through. --- src/__tests__/commands/setup.test.ts | 16 ++++++++++++++++ src/commands/setup.ts | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 724a0b798b..21843eddee 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -823,6 +823,22 @@ describe('handleSetupCommand', () => { }); }); + 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', { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 8c4eb6e35a..06efde6f02 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -944,9 +944,13 @@ function firecrawlMcpConfig( } { return { 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 ), }; From 72c65e275e92ade46554f4185fabec40904b6284 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 17:27:21 -0700 Subject: [PATCH 17/35] refactor(cli): write MCP config only for agents that share one contract setup mcp promises a global entry in a file we can parse, never a literal key, plus an optional rule file we own. Claude Code, Cursor, VS Code, and Codex meet that as registry data: the only difference is the file format. OpenCode is the same kind of write with its own map key and header form, so it stays. Hermes and OpenClaw were each a second product. Hermes needed a YAML writer, an owner-only create mode, an extra entry field for sign-in, and had no global rule file to own. OpenClaw needed a subprocess, a JSON5 config we cannot parse, a workspace probe, a duplicate credential builder, and a rule that could only be written if its AGENTS.md already existed. Neither shaped the product; both shaped the code around them. They stay supported and stop being written. `--hermes`, `--openclaw`, and their names on --agent print the server URL and succeed, the way an agent we install skills for but write no MCP config for already did. Skills and firecrawl launch are untouched, and the URL still works for both. Removing the two takes the whole launcher concept with them: the subprocess runner and its Windows argv escaping, the second credential path, launcher detection, the YAML writer, and the target/client split that existed only because launchers were not clients. Five agents, one contract, one writer. --- README.md | 9 +- src/__tests__/commands/setup.test.ts | 537 +++--------------------- src/__tests__/utils/mcp-install.test.ts | 119 ------ src/commands/setup.ts | 430 +++---------------- src/index.ts | 16 +- src/utils/mcp-clients.ts | 170 ++------ src/utils/mcp-install.ts | 77 +--- 7 files changed, 179 insertions(+), 1179 deletions(-) diff --git a/README.md b/README.md index 6c4147f4ca..1dcdce4c71 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,13 @@ 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. +prefer Firecrawl for web search and scraping. Setup writes config for Claude +Code, Cursor, VS Code, Codex, and OpenCode. + +Hermes Agent and OpenClaw are supported without being configured: each keeps +MCP somewhere setup cannot edit safely, so `--hermes` and `--openclaw` print +the server URL and succeed rather than editing their files. Skills and +`firecrawl launch` cover both as before. 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 diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 21843eddee..479eeed435 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -15,7 +15,6 @@ import { handleMakeDefaultCommand, handleSetupCommand, installMcp, - installOpenClawMcp, installSkillsForAgent, } from '../../commands/setup'; import { ALL_SKILL_REPOS } from '../../commands/skills-install'; @@ -405,17 +404,9 @@ describe('handleSetupCommand', () => { } }); - 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('lists only detected agents in the picker, already selected', async () => { mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); - mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + mkdirSync(path.join(sandboxHome, '.codex'), { recursive: true }); const { checkbox, confirm } = await import('@inquirer/prompts'); vi.mocked(checkbox).mockResolvedValue(['cursor']); @@ -434,7 +425,7 @@ describe('handleSetupCommand', () => { expect(vi.mocked(checkbox).mock.calls[0]?.[0]).toMatchObject({ choices: [ { value: 'cursor', checked: true }, - { value: 'hermes', checked: true }, + { value: 'codex', checked: true }, ], }); expect(existsSync(path.join(sandboxHome, '.cursor', 'mcp.json'))).toBe( @@ -451,59 +442,6 @@ describe('handleSetupCommand', () => { } }); - 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'); - }); - - await handleSetupCommand('mcp', { - clients: ['cursor', 'openclaw'], - yes: true, - }); - - expect( - JSON.parse( - readFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), 'utf-8') - ).mcpServers.firecrawl.url - ).toBe(MCP_URL); - }); - it('surfaces total failure even in quiet mode', async () => { mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); writeFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), '{ broken'); @@ -553,82 +491,6 @@ describe('handleSetupCommand', () => { ).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 }); - writeFileSync( - configPath, - 'theme: dark\nmcp_servers:\n existing:\n url: https://example.com/mcp\n', - { mode: 0o600 } - ); - - try { - await handleSetupCommand('mcp', { - agent: 'hermes', - global: true, - yes: true, - }); - - const config = readFileSync(configPath, 'utf-8'); - expect(config).toContain('theme: dark'); - expect(config).toContain('existing:'); - 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') { - expect(statSync(configPath).mode & 0o777).toBe(0o600); - } - } finally { - rmSync(home, { recursive: true, force: true }); - } - }); - - it('keeps an environment-backed key indirect in Hermes config', async () => { - const home = mkdtempSync( - path.join(os.tmpdir(), 'firecrawl-hermes-env-test-') - ); - process.env.HOME = home; - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - - try { - await installMcp({ agent: 'hermes' }); - - const config = readFileSync( - path.join(home, '.hermes', 'config.yaml'), - 'utf-8' - ); - expect(config).toContain('Authorization: Bearer ${FIRECRAWL_API_KEY}'); - expect(config).not.toContain('Bearer fc-test-key'); - } finally { - rmSync(home, { recursive: true, force: true }); - } - }); - - it('honors explicit keyless setup for Hermes even when a key is stored', async () => { - const home = mkdtempSync( - path.join(os.tmpdir(), 'firecrawl-hermes-keyless-test-') - ); - process.env.HOME = home; - - try { - await installMcp({ agent: 'hermes', keyless: true }); - - const config = readFileSync( - path.join(home, '.hermes', 'config.yaml'), - 'utf-8' - ); - expect(config).toContain('https://mcp.firecrawl.dev/v2/mcp'); - expect(config).not.toContain('Authorization'); - expect(config).not.toContain('fc-test-key'); - } finally { - rmSync(home, { recursive: true, force: true }); - } - }); - it('suppresses Hermes installer logs in quiet mode', async () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -642,212 +504,101 @@ describe('handleSetupCommand', () => { } }); - 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'; - - await installOpenClawMcp(); - - const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; - expect(config).toContain('Bearer ${FIRECRAWL_API_KEY}'); - expect(config).not.toContain('Bearer fc-test-key'); - }); - - it('honors explicit keyless setup for OpenClaw even when a key is stored', async () => { - await installMcp({ agent: 'openclaw', keyless: true }); - - const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; - expect(config).toContain('https://mcp.firecrawl.dev/v2/mcp'); - expect(config).not.toContain('Authorization'); - expect(config).not.toContain('fc-test-key'); - }); - - it('surfaces a sanitized OpenClaw setup failure', async () => { + it('points every agent at the sign-in endpoint with --oauth', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - vi.mocked(execFileSync).mockImplementationOnce(() => { - throw new Error('spawn failed with Authorization: Bearer fc-test-key'); - }); - - await expect(installOpenClawMcp()).rejects.toThrow( - 'Failed to configure Firecrawl MCP for OpenClaw. Verify that OpenClaw is installed and available on PATH.' - ); - }); + for (const dir of ['.claude', '.cursor', '.codex', '.hermes']) { + mkdirSync(path.join(sandboxHome, dir), { recursive: true }); + } - 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 }); + await handleSetupCommand('mcp', { oauth: true, yes: true } as never); - const hermes = readFileSync( - path.join(sandboxHome, '.hermes', 'config.yaml'), + const claude = readFileSync( + path.join(sandboxHome, '.claude.json'), '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'); + 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'); - // 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); + // 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('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('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('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; + it('prints the server URL for an agent it does not configure', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { + // Naming one is not an error, and nothing is written for it. await handleSetupCommand('mcp', { - clients: ['openclaw'], + urlOnly: ['hermes'], yes: true, - rules: true, } as never); - expect(readFileSync(path.join(moved, 'AGENTS.md'), 'utf-8')).toContain( - 'firecrawl_search' - ); + expect(log.mock.calls.flat().join(' ')).toContain(MCP_URL); + expect(existsSync(path.join(sandboxHome, '.hermes'))).toBe(false); } finally { - delete process.env.OPENCLAW_WORKSPACE_DIR; + log.mockRestore(); } }); - 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); + it('accepts those agents by name as well as by flag', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - 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'); + try { + for (const agent of ['hermes', 'hermes-agent', 'openclaw']) { + await handleSetupCommand('mcp', { agent, yes: true }); + } - // 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'); + expect(log.mock.calls.flat().join(' ')).toContain(MCP_URL); + expect(existsSync(path.join(sandboxHome, '.openclaw'))).toBe(false); + } finally { + log.mockRestore(); + } }); - it('arms the sign-in flow for agents that need more than a URL', async () => { - mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + it('still configures the writers when both kinds are named', async () => { + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); await handleSetupCommand('mcp', { - clients: ['hermes', 'openclaw'], - oauth: true, + clients: ['cursor'], + urlOnly: ['openclaw'], 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', - }); + JSON.parse(readFileSync(globalConfigPath('cursor', sandboxHome), 'utf-8')) + .mcpServers.firecrawl.url + ).toBe(MCP_URL); }); - 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('sends the sign-in URL for an agent it does not configure', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - it('refuses to combine sign-in with keyless', async () => { - await expect( - handleSetupCommand('mcp', { - clients: ['cursor'], + try { + await handleSetupCommand('mcp', { + urlOnly: ['openclaw'], oauth: true, - keyless: true, yes: true, - } as never) - ).rejects.toThrow(/either --oauth or --keyless/); + } as never); + + expect(log.mock.calls.flat().join(' ')).toContain(`${MCP_URL}-oauth`); + } finally { + log.mockRestore(); + } }); it('uses each client native environment binding with --agent all', async () => { @@ -883,9 +634,8 @@ describe('handleSetupCommand', () => { }); 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}'); + // `all` covers every agent setup writes for, and nothing else. + expect(existsSync(path.join(home, '.hermes', 'config.yaml'))).toBe(false); } finally { rmSync(home, { recursive: true, force: true }); } @@ -902,9 +652,9 @@ describe('handleSetupCommand', () => { yes: true, }); - expect( - readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') - ).toContain(MCP_URL); + expect(readFileSync(globalConfigPath('cursor', home), 'utf-8')).toContain( + MCP_URL + ); } finally { rmSync(home, { recursive: true, force: true }); } @@ -996,45 +746,6 @@ describe('handleSetupCommand', () => { } }); - it('does not print a stored OpenClaw credential when setup is rejected', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - await expect(installOpenClawMcp()).rejects.toThrow( - 'Export FIRECRAWL_API_KEY' - ); - - expect(log.mock.calls.flat().join(' ')).not.toContain('fc-test-key'); - }); - - 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); - - try { - await handleSetupCommand('mcp', { - agent: 'claude-code', - global: true, - yes: true, - }); - - 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('writes MCP into global agent config', async () => { vi.mocked(getApiKey).mockReturnValue(undefined); const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-global-')); @@ -1053,120 +764,6 @@ describe('handleSetupCommand', () => { // --- Windows: launch .cmd/.exe shims correctly (execFileSync cannot) --- - 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, 'openclaw.CMD'), '@exit /b 0\r\n'); - const originalPlatform = Object.getOwnPropertyDescriptor( - process, - 'platform' - ); - const originalPath = process.env.PATH; - const originalPathext = process.env.PATHEXT; - const originalComspec = process.env.ComSpec; - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32', - }); - process.env.PATH = bin; - process.env.PATHEXT = '.EXE;.CMD'; - process.env.ComSpec = 'cmd.exe'; - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - - try { - await handleSetupCommand('mcp', { - agent: 'openclaw', - global: true, - yes: true, - }); - - const call = vi.mocked(execFileSync).mock.calls[0]; - const command = call?.[0] as string; - const passthruArgs = call?.[1] as string[]; - const opts = call?.[2] as { windowsVerbatimArguments?: boolean }; - - 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, '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); - if (originalPath === undefined) delete process.env.PATH; - else process.env.PATH = originalPath; - if (originalPathext === undefined) delete process.env.PATHEXT; - else process.env.PATHEXT = originalPathext; - if (originalComspec === undefined) delete process.env.ComSpec; - else process.env.ComSpec = originalComspec; - rmSync(root, { recursive: true, force: true }); - } - }); - - it('launches a native executable directly on win32', async () => { - const bin = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-win-bin-')); - const openclawExe = path.join(bin, 'openclaw.EXE'); - writeFileSync(openclawExe, ''); - const originalPlatform = Object.getOwnPropertyDescriptor( - process, - 'platform' - ); - const originalPath = process.env.PATH; - const originalPathext = process.env.PATHEXT; - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32', - }); - process.env.PATH = bin; - process.env.PATHEXT = '.EXE;.CMD'; - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - - try { - await handleSetupCommand('mcp', { - agent: 'openclaw', - global: true, - yes: true, - }); - - const call = vi.mocked(execFileSync).mock.calls[0]; - const command = call?.[0] as string; - const args = call?.[1] as string[]; - const opts = call?.[2] as { windowsVerbatimArguments?: boolean }; - expect(command).toBe(openclawExe); - expect(args.join(' ')).toContain('Bearer ${FIRECRAWL_API_KEY}'); - expect(opts?.windowsVerbatimArguments).toBeUndefined(); - } finally { - if (originalPlatform) - Object.defineProperty(process, 'platform', originalPlatform); - if (originalPath === undefined) delete process.env.PATH; - else process.env.PATH = originalPath; - if (originalPathext === undefined) delete process.env.PATHEXT; - else process.env.PATHEXT = originalPathext; - rmSync(bin, { recursive: true, force: true }); - } - }); - - it('still spawns bare argv with no shell on non-win32', async () => { - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - // Sanity: the POSIX path stays argv-safe with no shell interpolation. - await handleSetupCommand('mcp', { - agent: 'openclaw', - global: true, - yes: true, - }); - - const call = vi.mocked(execFileSync).mock.calls[0]; - expect(call?.[0]).toBe('openclaw'); - expect( - Array.isArray(call?.[1]) && (call?.[1] as string[]).length - ).toBeGreaterThan(0); - expect((call?.[2] as { shell?: boolean })?.shell).toBeUndefined(); - }); - it('strips inherited npm_* env vars before nested npx calls', async () => { // Reproduces the bug where running this CLI under `npx -y firecrawl-cli@VERSION` // leaks npm_command/npm_lifecycle_event/npm_execpath into nested diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 535ae5a002..70bd76ad4e 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -13,12 +13,10 @@ import { resolveMcpClientId, type McpContext, } from '../../utils/mcp-clients'; -import { parse as parseYaml } from 'yaml'; import { appendRuleSection, setupMcpClient, upsertTomlServer, - upsertYamlServer, writeJsonServerEntry, } from '../../utils/mcp-install'; @@ -313,123 +311,6 @@ describe('mcp install', () => { }); }); - 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); - }); - }); - describe('appendRuleSection', () => { it('keeps existing content and replaces only the fenced section', async () => { const file = path.join(root, 'AGENTS.md'); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 06efde6f02..8194bfdb56 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -3,7 +3,7 @@ * Installs firecrawl skill files and MCP server into AI coding agents */ -import { execFileSync, execSync } from 'child_process'; +import { execSync } from 'child_process'; import { existsSync } from 'fs'; import os from 'os'; import path from 'path'; @@ -28,38 +28,32 @@ import { 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, + MCP_URL_ONLY_IDS, + MCP_URL_ONLY_NAMES, + resolveMcpUrlOnlyId, + type McpClientId, + type McpUrlOnlyId, } from '../utils/mcp-clients'; -import { - appendRuleSection, - setupMcpClient, - type McpClientResult, -} from '../utils/mcp-install'; +import { setupMcpClient, type McpClientResult } from '../utils/mcp-install'; export type SetupSubcommand = 'skills' | 'workflows' | 'mcp' | 'defaults'; type SetupIntegration = SetupSubcommand; type ResolvedMcpAgent = - | { kind: 'clients'; ids?: McpTargetId[] } - | { kind: 'launchers' } + | { kind: 'clients'; ids?: McpClientId[] } | { kind: 'skills-only'; agent: string } - | { kind: 'openclaw' } - | { kind: 'all-launchers' }; + /** Supported, but setup prints the URL instead of editing their config. */ + | { kind: 'url-only'; ids: McpUrlOnlyId[] } + | { kind: 'all' }; export interface SetupOptions { global?: boolean; @@ -76,7 +70,9 @@ export interface SetupOptions { /** 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[]; + clients?: McpClientId[]; + /** Supported agents named by flag that setup does not configure. */ + urlOnly?: McpUrlOnlyId[]; /** Force the Firecrawl web rules on or off instead of prompting. */ rules?: boolean; } @@ -97,108 +93,6 @@ const SKILL_REPO_LABELS: Record = { 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(oauth = false): string { return oauth ? FIRECRAWL_MCP_OAUTH_URL : FIRECRAWL_MCP_URL; } @@ -210,51 +104,6 @@ function isEnvironmentBackedApiKey( return Boolean(apiKey && runtimeEnv[ENV_API_KEY] === apiKey); } -function assertSubprocessSafeCredential( - apiKey?: string, - runtimeEnv: NodeJS.ProcessEnv = process.env -): void { - if (apiKey && !isEnvironmentBackedApiKey(apiKey, runtimeEnv)) { - throw new Error( - 'Secure MCP setup cannot persist a stored API key for future client sessions. Export FIRECRAWL_API_KEY, launch the client through "firecrawl launch ", or configure keyless MCP.' - ); - } -} - -function environmentHeaderForAgent(agent?: string): string | undefined { - switch (agent) { - case 'claude-code': - case 'hermes': - case 'openclaw': - return `Bearer \${${ENV_API_KEY}}`; - case 'cursor': - case 'vscode': - return `Bearer \${env:${ENV_API_KEY}}`; - case 'opencode': - return `Bearer {env:${ENV_API_KEY}}`; - default: - return undefined; - } -} - -function firecrawlMcpHeaders( - agent?: string, - apiKey?: string, - runtimeEnv: NodeJS.ProcessEnv = process.env -): Record | undefined { - if (!apiKey) return undefined; - - // Keep this helper safe in isolation. Callers currently reject stored keys - // before reaching it, but a future call site must not turn one into a raw - // Authorization header in argv or a client configuration file. - assertSubprocessSafeCredential(apiKey, runtimeEnv); - const environmentHeader = environmentHeaderForAgent(agent); - if (environmentHeader) return { Authorization: environmentHeader }; - throw new Error( - 'This MCP client does not have a verified environment-variable syntax. Choose a supported --agent, use --agent all, or configure the client manually so FIRECRAWL_API_KEY is not persisted as a literal.' - ); -} - function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { if (!agent) return { kind: 'clients' }; @@ -262,22 +111,19 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { switch (normalized) { case '*': case 'all': - return { kind: 'all-launchers' }; - case 'launchers': - case 'launcher': - return { kind: 'launchers' }; - case 'openclaw': - return { kind: 'openclaw' }; + return { kind: 'all' }; default: { const id = resolveMcpClientId(normalized); if (id) return { kind: 'clients', ids: [id] }; + const urlOnly = resolveMcpUrlOnlyId(normalized); + if (urlOnly) return { kind: 'url-only', ids: [urlOnly] }; // 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.` + `Unknown agent "${agent}" for setup mcp. Use one of: ${[...ALL_MCP_CLIENT_IDS, ...MCP_URL_ONLY_IDS].join(', ')}, all.` ); } } @@ -544,6 +390,24 @@ export async function installSkillsForAgent( ); } +/** The endpoint this run points agents at, which sign-in changes. */ +function mcpUrlFor(options: SetupOptions): string { + return options.oauth ? FIRECRAWL_MCP_OAUTH_URL : FIRECRAWL_MCP_URL; +} + +/** + * Report the agents Firecrawl supports but does not configure. Naming one is + * not an error: the run succeeds and prints the URL so the person can point + * the agent at it themselves. + */ +function reportUrlOnly(ids: McpUrlOnlyId[], options: SetupOptions): void { + for (const id of ids) { + console.log( + `${MCP_URL_ONLY_NAMES[id]}: Firecrawl does not write its MCP config. Point it at ${mcpUrlFor(options)} to connect it yourself.` + ); + } +} + export async function installMcp( options: SetupOptions, // `firecrawl launch` may provide the exact environment inherited by the @@ -551,39 +415,33 @@ export async function installMcp( // without mutating the parent shell or exposing the key to setup commands. runtimeEnv: NodeJS.ProcessEnv = process.env ): Promise { - const apiKey = options.keyless ? undefined : getApiKey(); + // A flag naming an agent we support but do not configure is answered with + // the URL rather than treated as an error. + if (options.urlOnly?.length) { + reportUrlOnly(options.urlOnly, options); + if (!options.clients?.length && !options.agent) return; + } + const resolvedAgent = resolveMcpAgent(options.agent); - // 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.` + `Firecrawl does not write MCP config for ${resolvedAgent.agent}. Point it at ${mcpUrlFor(options)} to connect it yourself.` ); return; } - if (resolvedAgent.kind === 'openclaw') { - // Routed through the same reporter as every other target so the keyless - // fallback is stated rather than implied by a bare installer log line. - await installMcpClients({ ...options, yes: true }, runtimeEnv, [ - resolvedAgent.kind, - ]); + if (resolvedAgent.kind === 'url-only') { + reportUrlOnly(resolvedAgent.ids, options); return; } - if (resolvedAgent.kind === 'launchers') { + if (resolvedAgent.kind === 'all') { await installMcpClients({ ...options, yes: true }, runtimeEnv, [ - ...ALL_MCP_LAUNCHER_IDS, + ...ALL_MCP_CLIENT_IDS, ]); - return; - } - if (resolvedAgent.kind === 'all-launchers') { - await installMcpClients({ ...options, yes: true }, runtimeEnv, undefined, { - includeAllLaunchers: true, - }); + reportUrlOnly([...MCP_URL_ONLY_IDS], options); return; } @@ -603,10 +461,10 @@ function displayPath(target: string, ctx: McpContext): string { } async function pickMcpClients( - detected: readonly McpTargetId[] -): Promise { + detected: readonly McpClientId[] +): Promise { const { checkbox } = await import('@inquirer/prompts'); - return checkbox({ + return checkbox({ message: 'Which agents do you want to set up?', loop: false, pageSize: detected.length, @@ -624,104 +482,6 @@ async function pickMcpClients( * out of reach here, so the launcher itself is the authority. Falls back to the * documented defaults whenever the CLI cannot answer. */ -function openclawConfiguredWorkspace( - runtimeEnv: NodeJS.ProcessEnv, - id: McpLauncherId -): string | undefined { - if (id !== 'openclaw') return undefined; - try { - const stdout = execFileSync( - 'openclaw', - ['config', 'get', 'agents.defaults.workspace', '--json'], - { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - env: cleanNpmEnv(), - } - ); - const value: unknown = JSON.parse(stdout); - if (typeof value !== 'string' || value === '') return undefined; - const expanded = value.startsWith('~') - ? path.join(os.homedir(), value.slice(1)) - : value; - return path.join(expanded, 'AGENTS.md'); - } catch { - return undefined; - } -} - -/** - * 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: '', - auth: keyless ? 'keyless' : 'env', - ruleStatus: 'unsupported', - ruleDetail: '', - }; - - 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); - } - - const rule = MCP_LAUNCHER_RULES[id]; - if (!rule) return result; - if (!rules) { - // The launcher does take rules; the run just did not ask for them. - result.ruleStatus = 'skipped'; - return result; - } - - const rulePath = - openclawConfiguredWorkspace(runtimeEnv, 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; - } - - try { - result.ruleStatus = await appendRuleSection(rulePath, rule.content); - result.ruleDetail = rulePath; - } 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({ @@ -734,7 +494,7 @@ async function confirmMcpRules(): Promise { async function installMcpClients( options: SetupOptions, runtimeEnv: NodeJS.ProcessEnv, - explicitIds?: McpTargetId[], + explicitIds?: McpClientId[], { includeAllLaunchers = false } = {} ): Promise { if (options.oauth && options.keyless) { @@ -765,15 +525,10 @@ async function installMcpClients( // Prompts only make sense when someone is there to answer them. const nonInteractive = Boolean(options.yes) || !process.stdin.isTTY; - let selected = includeAllLaunchers - ? [...ALL_MCP_CLIENT_IDS] - : (explicitIds ?? options.clients); + let selected = explicitIds ?? options.clients; if (!selected || selected.length === 0) { - const detected: McpTargetId[] = [ - ...(await detectMcpClients(ctx)), - ...detectMcpLaunchers(ctx), - ]; - if (detected.length === 0 && !includeAllLaunchers) { + const detected: McpClientId[] = await detectMcpClients(ctx); + if (detected.length === 0) { throw new Error( 'No coding agents detected. Pass an agent flag such as --claude or --cursor.' ); @@ -789,15 +544,6 @@ async function installMcpClients( } } - // `--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, - ]; - } - // `-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 = @@ -805,11 +551,7 @@ async function installMcpClients( const results: McpClientResult[] = []; for (const id of selected) { - results.push( - isMcpLauncherId(id) - ? await setupMcpLauncher(id, ctx, runtimeEnv, rules) - : await setupMcpClient(id, { rules, ctx }) - ); + results.push(await setupMcpClient(id, { rules, ctx })); } reportMcpResults(results, ctx, options, Boolean(apiKey)); @@ -875,9 +617,7 @@ function signInLine( 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; + const spec = MCP_CLIENTS[result.id].oauth; return spec ? ` Sign in ${dim}${spec.nextStep}${reset}` : undefined; } @@ -931,57 +671,3 @@ function reportMcpResults( throw new Error('Failed to configure Firecrawl MCP.'); } } - -function firecrawlMcpConfig( - agent?: string, - runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false, - oauth = false -): { - url: string; - headers?: Record; - transport?: string; -} { - return { - 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 || oauth ? undefined : getApiKey(), - runtimeEnv - ), - }; -} - -export async function installOpenClawMcp( - runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false, - /** Suppress standalone logging when a caller renders its own summary. */ - quiet = false, - oauth = false -): Promise { - const config = { - ...firecrawlMcpConfig('openclaw', runtimeEnv, keyless, oauth), - transport: 'streamable-http', - ...(oauth ? MCP_LAUNCHER_OAUTH.openclaw?.entry : undefined), - }; - if (!quiet) console.log('Configuring Firecrawl MCP for OpenClaw...\n'); - - try { - runClientCommand( - 'openclaw', - ['mcp', 'set', 'firecrawl', JSON.stringify(config)], - { - stdio: 'pipe', - env: cleanNpmEnv(), - } - ); - } catch { - throw new Error( - 'Failed to configure Firecrawl MCP for OpenClaw. Verify that OpenClaw is installed and available on PATH.' - ); - } -} diff --git a/src/index.ts b/src/index.ts index aa8ff14fc4..576ff10a0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,7 +60,12 @@ 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 { + ALL_MCP_TARGET_IDS, + MCP_URL_ONLY_IDS, + MCP_URL_ONLY_NAMES, + mcpTargetName, +} from './utils/mcp-clients'; import { handleEnvPullCommand } from './commands/env'; import { handleStatusCommand } from './commands/status'; import { handleDoctorCommand } from './commands/doctor'; @@ -2264,6 +2269,14 @@ const setupCommand = program for (const id of ALL_MCP_TARGET_IDS) { setupCommand.option(`--${id}`, `Set up ${mcpTargetName(id)} (mcp)`); } +// Supported agents we do not configure still take a flag, so naming one +// succeeds with the server URL instead of failing as unknown. +for (const id of MCP_URL_ONLY_IDS) { + setupCommand.option( + `--${id}`, + `Show the MCP URL for ${MCP_URL_ONLY_NAMES[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. @@ -2294,6 +2307,7 @@ Examples: await handleSetupCommand(subcommand, { ...options, clients: ALL_MCP_TARGET_IDS.filter((id) => options[id] === true), + urlOnly: MCP_URL_ONLY_IDS.filter((id) => options[id] === true), }); }); diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 13b45f7a7d..e4e8ea9758 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -24,25 +24,35 @@ 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'; +export type McpClientId = 'claude' | 'cursor' | 'vscode' | 'codex' | 'opencode'; /** - * 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. + * Agents Firecrawl supports without configuring. Setup writes a global entry + * to a file it can parse, never a literal key, plus an optional rule file it + * owns. These agents do not share that contract: each needs its own writer, + * its own credential shape, or a subprocess. Setup prints the server URL for + * them instead, and skills and `firecrawl launch` are unaffected. */ -export type McpLauncherId = 'openclaw'; +export const MCP_URL_ONLY_IDS = ['hermes', 'openclaw'] as const; +export type McpUrlOnlyId = (typeof MCP_URL_ONLY_IDS)[number]; + +export const MCP_URL_ONLY_NAMES: Record = { + hermes: 'Hermes Agent', + openclaw: 'OpenClaw', +}; -export type McpTargetId = McpClientId | McpLauncherId; +const URL_ONLY_ALIASES: Record = { + hermes: 'hermes', + 'hermes-agent': 'hermes', + openclaw: 'openclaw', +}; + +export function resolveMcpUrlOnlyId(agent: string): McpUrlOnlyId | undefined { + const alias = agent.trim().toLowerCase(); + return Object.prototype.hasOwnProperty.call(URL_ONLY_ALIASES, alias) + ? URL_ONLY_ALIASES[alias] + : undefined; +} /** * `env` writes an indirect reference to `FIRECRAWL_API_KEY`, which only works @@ -87,15 +97,10 @@ export interface McpOauthSpec { export interface McpClient { id: McpClientId; name: string; - format: 'json' | 'toml' | 'yaml'; + format: 'json' | 'toml'; /** 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; @@ -294,28 +299,6 @@ export const MCP_CLIENTS: Record = { oauth: { nextStep: 'OpenCode opens the browser on first use' }, detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], }, - hermes: { - id: 'hermes', - name: 'Hermes Agent', - format: 'yaml', - serversKey: 'mcp_servers', - globalConfigPath: (ctx) => path.join(ctx.home, '.hermes', '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. - oauth: { - entry: { auth: 'oauth' }, - nextStep: 'Hermes opens the browser on first use', - }, - detectPaths: (ctx) => [path.join(ctx.home, '.hermes')], - }, }; export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ @@ -324,105 +307,12 @@ export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ 'vscode', 'codex', 'opencode', - 'hermes', ]; -export const MCP_LAUNCHER_NAMES: Record = { - openclaw: 'OpenClaw', -}; +export const ALL_MCP_TARGET_IDS: readonly McpClientId[] = ALL_MCP_CLIENT_IDS; -/** - * 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: 'run 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. - */ -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 (existsSync(path.join(entry, `${name}${extension}`))) 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)); +export function mcpTargetName(id: McpClientId): string { + return MCP_CLIENTS[id].name; } /** Aliases accepted by `--agent`, including the names `firecrawl launch` uses. */ @@ -440,8 +330,6 @@ const CLIENT_ALIASES: Record = { 'codex-gui': 'codex', opencode: 'opencode', 'open-code': 'opencode', - hermes: 'hermes', - 'hermes-agent': 'hermes', }; export function resolveMcpClientId(agent: string): McpClientId | undefined { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index cb58423697..d0674b05f6 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -12,7 +12,6 @@ import { promises as fs } from 'fs'; import path from 'path'; import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser'; -import { parseDocument } from 'yaml'; import { MCP_CLIENTS, MCP_SERVER_NAME, @@ -21,7 +20,6 @@ import { type McpClient, type McpClientId, type McpContext, - type McpTargetId, } from './mcp-clients'; export type McpStatus = 'configured' | 'reconfigured' | 'failed'; @@ -33,7 +31,7 @@ export type RuleStatus = | 'failed'; export interface McpClientResult { - id: McpTargetId; + id: McpClientId; name: string; mcpStatus: McpStatus; /** Config path on success, error message on failure. */ @@ -60,12 +58,10 @@ async function readIfExists(filePath: string): Promise { async function writeFileEnsuringDir( filePath: string, - content: string, - /** Applied by the OS only when the file is created, never to an existing one. */ - createMode?: number + content: string ): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, content, { encoding: 'utf8', mode: createMode }); + await fs.writeFile(filePath, content, 'utf8'); } function escapeRegExp(value: string): string { @@ -133,50 +129,6 @@ export async function writeJsonServerEntry( 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]); - // 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. - if (doc.getIn([serversKey]) === null) { - const empty = doc.getIn([serversKey], true) 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); - } - 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, - }; -} - /** * True when the character at `index` is escaped. Backslashes escape each other, * so only an odd run of them before the position leaves it escaped. @@ -387,29 +339,6 @@ async function writeMcpEntry( ? { ...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 = {}; From 414e8ab4254186609014eb2a407c88336eda5a70 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 17:57:58 -0700 Subject: [PATCH 18/35] fix(cli): check the auth modes before any early return, drop the unused yaml dep Two defects reported against the scope change: * The URL-only branch returns before the writers run, and the mutual-exclusion check lived with the writers, so `--hermes --oauth --keyless` printed a sign-in URL and exited zero while `--cursor --oauth --keyless` was rejected. The check now runs at the top of installMcp, ahead of everything that reports or returns. * Removing the Hermes writer took the last import of the yaml package with it. Nothing under src/ imports it now, so it is dropped from the manifest and the lockfile rather than shipped unused. A third finding, that a test leaks FIRECRAWL_API_KEY into later tests, does not hold: beforeEach deletes that variable and afterEach restores the original, so each test starts without it. The assignment is also load-bearing where it is, since the assertion it supports is that sign-in drops a key that was present. --- package.json | 1 - pnpm-lock.yaml | 3 --- src/__tests__/commands/setup.test.ts | 12 ++++++++++++ src/commands/setup.ts | 14 ++++++++------ 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index ef0c8f1ed6..78fb57ca30 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "commander": "^14.0.2", "firecrawl": "4.24.0", "jsonc-parser": "3.3.1", - "yaml": "^2.9.0", "zod-to-json-schema": "3.24.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b056306794..0650b2bfcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: jsonc-parser: specifier: 3.3.1 version: 3.3.1 - yaml: - specifier: ^2.9.0 - version: 2.9.0 zod-to-json-schema: specifier: 3.24.6 version: 3.24.6(zod@3.25.76) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 479eeed435..6d0c8563e9 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -538,6 +538,18 @@ describe('handleSetupCommand', () => { ).rejects.toThrow(/either --oauth or --keyless/); }); + it('rejects that combination for an agent it only prints a URL for', async () => { + // This path returns early, so the check has to run ahead of it. + await expect( + handleSetupCommand('mcp', { + urlOnly: ['hermes'], + oauth: true, + keyless: true, + yes: true, + } as never) + ).rejects.toThrow(/either --oauth or --keyless/); + }); + it('prints the server URL for an agent it does not configure', async () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 8194bfdb56..b4ccabf8a2 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -415,6 +415,14 @@ export async function installMcp( // without mutating the parent shell or exposing the key to setup commands. runtimeEnv: NodeJS.ProcessEnv = process.env ): Promise { + // Checked before anything else reports or returns, so an agent we only print + // a URL for cannot 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.' + ); + } + // A flag naming an agent we support but do not configure is answered with // the URL rather than treated as an error. if (options.urlOnly?.length) { @@ -497,12 +505,6 @@ async function installMcpClients( explicitIds?: McpClientId[], { includeAllLaunchers = false } = {} ): Promise { - if (options.oauth && options.keyless) { - throw new Error( - 'Choose either --oauth or --keyless. Signing in and running anonymously are different endpoints.' - ); - } - 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 From 554a3c382ca0cca874a421bd656d223c21fee25d Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 18:03:20 -0700 Subject: [PATCH 19/35] docs(readme): fold the MCP setup changes into the existing prose The sign-in mode had its own lead-in, code block, and paragraph, and the two agents setup no longer configures had a paragraph explaining why. Both restate structure the section already has: one paragraph names the supported agents, and one paragraph covers how the credential is handled. Sign-in now sits in that credential paragraph beside keyless, and the two unwritten agents sit in the sentence that already lists the supported set. Same facts, no new sections, nine fewer lines. The harness table above is scoped to skills and stays accurate as written. --- README.md | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 1dcdce4c71..7062f29fb6 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,9 @@ 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. Setup writes config for Claude -Code, Cursor, VS Code, Codex, and OpenCode. - -Hermes Agent and OpenClaw are supported without being configured: each keeps -MCP somewhere setup cannot edit safely, so `--hermes` and `--openclaw` print -the server URL and succeed rather than editing their files. Skills and -`firecrawl launch` cover both as before. +prefer Firecrawl for web search and scraping. Setup configures Claude Code, +Cursor, VS Code, Codex, and OpenCode. `--hermes` and `--openclaw` print the +server URL instead, for agents Firecrawl supports but does not configure. 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 @@ -108,19 +104,10 @@ 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. - -To sign in from the agent instead of carrying a key, use `--oauth`: - -```bash -firecrawl setup mcp --oauth # sign in from each agent's browser -``` - -This writes the sign-in endpoint rather than a credential, and each agent starts -the browser flow itself the first time it connects. Setup prints the step each -agent needs, since they differ: `/mcp` in Claude Code, `codex mcp login -firecrawl` for Codex, Cursor Settings, and a browser on first use elsewhere. -`--oauth` and `--keyless` are different endpoints, so pass only one. +`--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: From 9f1567573a2a94ea29a6dc81e4b01951591e6d65 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 18:10:33 -0700 Subject: [PATCH 20/35] fix(cli): never leave a rule without the server it points at A rule tells an agent to prefer firecrawl_search and firecrawl_scrape. It was written even when the server entry for that agent had failed, so an agent could be instructed to reach for tools it had no way to call. The rule now depends on the entry landing. The dependency runs one way only: a failed rule still leaves a working MCP server, which is what keeps the two writes separate. A run that configured some of the chosen agents also exited zero, so a script could not tell a partial install from a complete one. The summary already named what failed; the exit code now agrees with it. Quiet mode is unchanged, since it runs inside init and launch, which report their own outcome and continue. Also ignores .cursor/, alongside the editor directories already listed. Nothing from it was ever tracked, but it is the same kind of workspace artifact a broad `git add` swept into the repo once already. --- .gitignore | 1 + src/__tests__/commands/setup.test.ts | 16 ++++++++++++++++ src/__tests__/utils/mcp-install.test.ts | 16 ++++++++++++++++ src/commands/setup.ts | 10 ++++++++-- src/utils/mcp-install.ts | 5 ++++- 5 files changed, 45 insertions(+), 3 deletions(-) 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/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 6d0c8563e9..fd3b40b5df 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -442,6 +442,22 @@ describe('handleSetupCommand', () => { } }); + 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('surfaces total failure even in quiet mode', async () => { mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); writeFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), '{ broken'); diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 70bd76ad4e..72cc2c4493 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + existsSync, mkdtempSync, mkdirSync, readFileSync, @@ -444,6 +445,21 @@ describe('mcp install', () => { expect(result.ruleStatus).toBe('failed'); }); + it('writes no rule for an agent whose MCP entry failed', 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: 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, '.cursor', 'rules', 'firecrawl.mdc')) + ).toBe(false); + }); + it('reports failure without touching an unparseable config', async () => { const file = path.join(ctx.home, '.cursor', 'mcp.json'); mkdirSync(path.dirname(file), { recursive: true }); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index b4ccabf8a2..ecb8c089d8 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -669,7 +669,13 @@ function reportMcpResults( console.log(`${dim}${note}${reset}`); } - if (succeeded.length === 0) { - throw new Error('Failed to configure Firecrawl MCP.'); + // 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(', ')}.` + ); } } diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index d0674b05f6..bd78361d7f 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -415,7 +415,10 @@ export async function setupMcpClient( result.mcpDetail = error instanceof Error ? error.message : String(error); } - if (!options.rules) return result; + // 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); From f7ca0f6836bf6181ba35f8fce6c461b74392c5cc Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 18:16:36 -0700 Subject: [PATCH 21/35] fix(cli): keep the unwritten agents reported when a writer fails Failing the run on a partial install swallowed the tail of `--agent all`: the throw left before the Hermes and OpenClaw lines printed, and their URL is the whole answer for those two, so the run that most needed to mention them was the one that did not. The report now runs whether or not the writers threw. The exit code is unchanged. Configuring what it can and then reporting failure is the same contract as before, since per-agent isolation is about the other agents still being configured, not about the command claiming success. --- src/__tests__/commands/setup.test.ts | 20 ++++++++++++++++++++ src/commands/setup.ts | 14 ++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index fd3b40b5df..71e587e6b9 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -458,6 +458,26 @@ describe('handleSetupCommand', () => { expect(existsSync(path.join(sandboxHome, '.claude.json'))).toBe(true); }); + it('still shows the unwritten agents when --agent all partly fails', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); + writeFileSync(globalConfigPath('cursor', sandboxHome), '{ oops'); + + try { + // The URL is the whole answer for those agents, so a writer failing + // must not swallow it. + await expect( + handleSetupCommand('mcp', { agent: 'all', yes: true }) + ).rejects.toThrow(/Cursor/); + + const output = log.mock.calls.flat().join(' '); + expect(output).toContain('Hermes Agent'); + expect(output).toContain('OpenClaw'); + } finally { + log.mockRestore(); + } + }); + it('surfaces total failure even in quiet mode', async () => { mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); writeFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), '{ broken'); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index ecb8c089d8..c625c1811f 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -446,10 +446,16 @@ export async function installMcp( return; } if (resolvedAgent.kind === 'all') { - await installMcpClients({ ...options, yes: true }, runtimeEnv, [ - ...ALL_MCP_CLIENT_IDS, - ]); - reportUrlOnly([...MCP_URL_ONLY_IDS], options); + // `all` covers the agents we do not configure too, and their URL is the + // whole answer for them. A writer failing must not swallow it, so the + // report runs before the failure leaves this function. + try { + await installMcpClients({ ...options, yes: true }, runtimeEnv, [ + ...ALL_MCP_CLIENT_IDS, + ]); + } finally { + reportUrlOnly([...MCP_URL_ONLY_IDS], options); + } return; } From b682dabf98c6627285726431458bfa29b3b6faa6 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 19:21:06 -0700 Subject: [PATCH 22/35] fix(cli): keep launch and the agent flags honest about what MCP setup did installMcp is shared, so making Hermes and OpenClaw URL-only reached callers this branch never edited. Three consequences, all in that seam: * `firecrawl launch hermes --install` printed the URL for an agent we do not configure and then said it was configured with Firecrawl MCP. Launch now claims MCP only when the run actually wrote it, which also covers --skip-mcp, where the same line was already claiming a write that never happened. * Naming an agent we do not configure returned from the whole run, so `--agent hermes --cursor` printed the URL and left Cursor unconfigured. The URL is now reported for whichever flag named it and the rest of the run continues; it ends early only when nothing else was named. * Detecting no agents at all is still an error, since nothing was configured and the run should not report success. It now names the server URL alongside the agent flags, so a machine we cannot detect still learns how to connect. The launch target table keeps its `mcpAgent` entries for those two agents: the call is what surfaces the URL, and dropping it would trade a false claim for silence. --- src/__tests__/commands/launch.test.ts | 46 +++++++++++++++++++++++++++ src/__tests__/commands/setup.test.ts | 13 ++++++++ src/commands/launch.ts | 14 +++++++- src/commands/setup.ts | 28 ++++++++-------- 4 files changed, 87 insertions(+), 14 deletions(-) diff --git a/src/__tests__/commands/launch.test.ts b/src/__tests__/commands/launch.test.ts index 600ddcfc71..433d7e4308 100644 --- a/src/__tests__/commands/launch.test.ts +++ b/src/__tests__/commands/launch.test.ts @@ -317,6 +317,52 @@ describe('handleLaunchCommand', () => { expect(installSkillsForAgent).not.toHaveBeenCalled(); }); + it('does not claim MCP for a target Firecrawl does not configure', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await handleLaunchCommand('hermes', { install: true, skipSkills: true }); + + const output = log.mock.calls.flat().join(' '); + expect(output).toContain('Hermes Agent is set up.'); + expect(output).not.toContain('configured with Firecrawl MCP'); + } finally { + log.mockRestore(); + } + }); + + it('still claims MCP for a target it does configure', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await handleLaunchCommand('codex', { install: true, skipSkills: true }); + + expect(log.mock.calls.flat().join(' ')).toContain( + 'Codex is configured with Firecrawl MCP.' + ); + } finally { + log.mockRestore(); + } + }); + + it('does not claim MCP when the run skipped it', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await handleLaunchCommand('codex', { + install: true, + skipMcp: true, + skipSkills: true, + }); + + expect(log.mock.calls.flat().join(' ')).not.toContain( + 'configured with Firecrawl MCP' + ); + } finally { + log.mockRestore(); + } + }); + 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 71e587e6b9..f725bea313 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -618,6 +618,19 @@ describe('handleSetupCommand', () => { } }); + it('configures the writers named beside an unwritten agent', async () => { + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); + + // --agent names one we do not configure; the flag names one we do. + await handleSetupCommand('mcp', { + agent: 'hermes', + clients: ['cursor'], + yes: true, + } as never); + + expect(existsSync(globalConfigPath('cursor', sandboxHome))).toBe(true); + }); + it('still configures the writers when both kinds are named', async () => { mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); diff --git a/src/commands/launch.ts b/src/commands/launch.ts index e0b1bcc00f..109689e376 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -4,6 +4,7 @@ import path from 'path'; import readline from 'readline'; import { spawnSync } from 'child_process'; import { installMcp, installSkillsForAgent } from './setup'; +import { resolveMcpUrlOnlyId } from '../utils/mcp-clients'; import { ALL_SKILL_REPOS } from './skills-install'; import { getApiKey } from '../utils/config'; @@ -249,6 +250,11 @@ export async function handleLaunchCommand( } const targetSupportsMcp = Boolean(target.mcpAgent); + // Some targets are supported without their MCP config being written; setup + // prints their server URL instead. Launch must not then claim otherwise. + const targetWritesMcp = Boolean( + target.mcpAgent && !resolveMcpUrlOnlyId(target.mcpAgent) + ); const targetSupportsSkills = Boolean(target.skillsAgent); let installMcpForTarget = targetSupportsMcp && !options.skipMcp; let installSkillsForTarget = targetSupportsSkills && !options.skipSkills; @@ -301,7 +307,13 @@ export async function handleLaunchCommand( } if (installOnly) { - console.log(`${target.displayName} is configured with Firecrawl MCP.`); + // Only claim MCP when this run actually wrote it: --skip-mcp and the + // targets we do not configure both end up here having written none. + console.log( + installMcpForTarget && targetWritesMcp + ? `${target.displayName} is configured with Firecrawl MCP.` + : `${target.displayName} is set up.` + ); return; } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index c625c1811f..ff63554b61 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -423,13 +423,6 @@ export async function installMcp( ); } - // A flag naming an agent we support but do not configure is answered with - // the URL rather than treated as an error. - if (options.urlOnly?.length) { - reportUrlOnly(options.urlOnly, options); - if (!options.clients?.length && !options.agent) return; - } - const resolvedAgent = resolveMcpAgent(options.agent); if (resolvedAgent.kind === 'skills-only') { @@ -441,10 +434,6 @@ export async function installMcp( return; } - if (resolvedAgent.kind === 'url-only') { - reportUrlOnly(resolvedAgent.ids, options); - return; - } if (resolvedAgent.kind === 'all') { // `all` covers the agents we do not configure too, and their URL is the // whole answer for them. A writer failing must not swallow it, so the @@ -459,7 +448,20 @@ export async function installMcp( return; } - await installMcpClients(options, runtimeEnv, resolvedAgent.ids); + // Every agent named this run that setup does not configure, whichever flag + // form named it. Reported once, and never in place of the rest of the run. + const urlOnly = [ + ...(options.urlOnly ?? []), + ...(resolvedAgent.kind === 'url-only' ? resolvedAgent.ids : []), + ].filter((id, index, all) => all.indexOf(id) === index); + if (urlOnly.length > 0) reportUrlOnly(urlOnly, options); + + const explicitIds = + resolvedAgent.kind === 'clients' ? resolvedAgent.ids : undefined; + // The URL was the whole request only when nothing else was named. + if (urlOnly.length > 0 && !explicitIds && !options.clients?.length) return; + + await installMcpClients(options, runtimeEnv, explicitIds); } /** Shorten a path for display: relative inside the project, `~` under home. */ @@ -538,7 +540,7 @@ async function installMcpClients( const detected: McpClientId[] = await detectMcpClients(ctx); if (detected.length === 0) { throw new Error( - 'No coding agents detected. Pass an agent flag such as --claude or --cursor.' + `No coding agents detected. Pass an agent flag such as --claude or --cursor, or point one at ${mcpUrlFor(options)} yourself.` ); } if (nonInteractive) { From e9c0191d1cd9b31676b9882a67220d9ba452d83f Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 19:22:11 -0700 Subject: [PATCH 23/35] fix(cli): indent the unwritten-agent lines with the rest of quiet output init and launch render an indented block, and these lines sat flush against it. --- src/commands/setup.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/setup.ts b/src/commands/setup.ts index ff63554b61..37d59099ed 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -401,9 +401,12 @@ function mcpUrlFor(options: SetupOptions): string { * the agent at it themselves. */ function reportUrlOnly(ids: McpUrlOnlyId[], options: SetupOptions): void { + // Quiet mode is embedded in init and launch, which indent every line they + // render, so these sit with the rest of that output rather than beside it. + const indent = options.quiet ? ' ' : ''; for (const id of ids) { console.log( - `${MCP_URL_ONLY_NAMES[id]}: Firecrawl does not write its MCP config. Point it at ${mcpUrlFor(options)} to connect it yourself.` + `${indent}${MCP_URL_ONLY_NAMES[id]}: Firecrawl does not write its MCP config. Point it at ${mcpUrlFor(options)} to connect it yourself.` ); } } From aa1a2ba0512ee882ceb5a3b8e812f9cbec21f629 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 20:44:46 -0700 Subject: [PATCH 24/35] Revert "write MCP config only for agents that share one contract" This reverts the scope reduction and everything built on it. The PR keeps the harnesses main already supports and changes only how MCP gets installed: Claude Code, Cursor, VS Code, Codex, OpenCode, Hermes Agent, and OpenClaw, with native writers in place of the add-mcp subprocess. Narrowing the set to the agents that share one contract is a real argument, but it is a different change: it drops harnesses, so it has to move `firecrawl launch` and the docs with it, and it should be judged on its own rather than riding along with the writer swap. It comes back as "stop auto-configuring Hermes and OpenClaw" if we want it. So the Hermes YAML writer and the OpenClaw launcher are back, `launch` configures both exactly as it does on main, and the URL-only path invented here is gone rather than left as a second way an agent can be supported. Kept from the reverted range, since none of it depends on the smaller set: the auth-mode check ahead of every branch, rules not being written for an agent whose server entry failed, a partial run failing the command, and .cursor/ in .gitignore. The launcher isolation test now asserts both halves of that last one: OpenClaw failing is reported, and Cursor beside it is still configured. --- README.md | 5 +- package.json | 1 + pnpm-lock.yaml | 3 + src/__tests__/commands/launch.test.ts | 46 -- src/__tests__/commands/setup.test.ts | 578 +++++++++++++++++++----- src/__tests__/utils/mcp-install.test.ts | 119 +++++ src/commands/launch.ts | 14 +- src/commands/setup.ts | 457 +++++++++++++++---- src/index.ts | 16 +- src/utils/mcp-clients.ts | 170 +++++-- src/utils/mcp-install.ts | 77 +++- 11 files changed, 1192 insertions(+), 294 deletions(-) diff --git a/README.md b/README.md index 7062f29fb6..023be23532 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,8 @@ 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. Setup configures Claude Code, -Cursor, VS Code, Codex, and OpenCode. `--hermes` and `--openclaw` print the -server URL instead, for agents Firecrawl supports but does not configure. +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 diff --git a/package.json b/package.json index 78fb57ca30..ef0c8f1ed6 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "commander": "^14.0.2", "firecrawl": "4.24.0", "jsonc-parser": "3.3.1", + "yaml": "^2.9.0", "zod-to-json-schema": "3.24.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0650b2bfcf..b056306794 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: jsonc-parser: specifier: 3.3.1 version: 3.3.1 + yaml: + specifier: ^2.9.0 + version: 2.9.0 zod-to-json-schema: specifier: 3.24.6 version: 3.24.6(zod@3.25.76) diff --git a/src/__tests__/commands/launch.test.ts b/src/__tests__/commands/launch.test.ts index 433d7e4308..600ddcfc71 100644 --- a/src/__tests__/commands/launch.test.ts +++ b/src/__tests__/commands/launch.test.ts @@ -317,52 +317,6 @@ describe('handleLaunchCommand', () => { expect(installSkillsForAgent).not.toHaveBeenCalled(); }); - it('does not claim MCP for a target Firecrawl does not configure', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - try { - await handleLaunchCommand('hermes', { install: true, skipSkills: true }); - - const output = log.mock.calls.flat().join(' '); - expect(output).toContain('Hermes Agent is set up.'); - expect(output).not.toContain('configured with Firecrawl MCP'); - } finally { - log.mockRestore(); - } - }); - - it('still claims MCP for a target it does configure', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - try { - await handleLaunchCommand('codex', { install: true, skipSkills: true }); - - expect(log.mock.calls.flat().join(' ')).toContain( - 'Codex is configured with Firecrawl MCP.' - ); - } finally { - log.mockRestore(); - } - }); - - it('does not claim MCP when the run skipped it', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - try { - await handleLaunchCommand('codex', { - install: true, - skipMcp: true, - skipSkills: true, - }); - - expect(log.mock.calls.flat().join(' ')).not.toContain( - 'configured with Firecrawl MCP' - ); - } finally { - log.mockRestore(); - } - }); - 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 f725bea313..4be5144f2f 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -15,6 +15,7 @@ import { handleMakeDefaultCommand, handleSetupCommand, installMcp, + installOpenClawMcp, installSkillsForAgent, } from '../../commands/setup'; import { ALL_SKILL_REPOS } from '../../commands/skills-install'; @@ -404,9 +405,17 @@ describe('handleSetupCommand', () => { } }); + 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('lists only detected agents in the picker, already selected', async () => { mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); - mkdirSync(path.join(sandboxHome, '.codex'), { recursive: true }); + mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); const { checkbox, confirm } = await import('@inquirer/prompts'); vi.mocked(checkbox).mockResolvedValue(['cursor']); @@ -425,7 +434,7 @@ describe('handleSetupCommand', () => { expect(vi.mocked(checkbox).mock.calls[0]?.[0]).toMatchObject({ choices: [ { value: 'cursor', checked: true }, - { value: 'codex', checked: true }, + { value: 'hermes', checked: true }, ], }); expect(existsSync(path.join(sandboxHome, '.cursor', 'mcp.json'))).toBe( @@ -442,6 +451,63 @@ describe('handleSetupCommand', () => { } }); + 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 }); @@ -458,26 +524,6 @@ describe('handleSetupCommand', () => { expect(existsSync(path.join(sandboxHome, '.claude.json'))).toBe(true); }); - it('still shows the unwritten agents when --agent all partly fails', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); - writeFileSync(globalConfigPath('cursor', sandboxHome), '{ oops'); - - try { - // The URL is the whole answer for those agents, so a writer failing - // must not swallow it. - await expect( - handleSetupCommand('mcp', { agent: 'all', yes: true }) - ).rejects.toThrow(/Cursor/); - - const output = log.mock.calls.flat().join(' '); - expect(output).toContain('Hermes Agent'); - expect(output).toContain('OpenClaw'); - } finally { - log.mockRestore(); - } - }); - it('surfaces total failure even in quiet mode', async () => { mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); writeFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), '{ broken'); @@ -527,6 +573,82 @@ describe('handleSetupCommand', () => { ).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 }); + writeFileSync( + configPath, + 'theme: dark\nmcp_servers:\n existing:\n url: https://example.com/mcp\n', + { mode: 0o600 } + ); + + try { + await handleSetupCommand('mcp', { + agent: 'hermes', + global: true, + yes: true, + }); + + const config = readFileSync(configPath, 'utf-8'); + expect(config).toContain('theme: dark'); + expect(config).toContain('existing:'); + 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') { + expect(statSync(configPath).mode & 0o777).toBe(0o600); + } + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it('keeps an environment-backed key indirect in Hermes config', async () => { + const home = mkdtempSync( + path.join(os.tmpdir(), 'firecrawl-hermes-env-test-') + ); + process.env.HOME = home; + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + + try { + await installMcp({ agent: 'hermes' }); + + const config = readFileSync( + path.join(home, '.hermes', 'config.yaml'), + 'utf-8' + ); + expect(config).toContain('Authorization: Bearer ${FIRECRAWL_API_KEY}'); + expect(config).not.toContain('Bearer fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it('honors explicit keyless setup for Hermes even when a key is stored', async () => { + const home = mkdtempSync( + path.join(os.tmpdir(), 'firecrawl-hermes-keyless-test-') + ); + process.env.HOME = home; + + try { + await installMcp({ agent: 'hermes', keyless: true }); + + const config = readFileSync( + path.join(home, '.hermes', 'config.yaml'), + 'utf-8' + ); + expect(config).toContain('https://mcp.firecrawl.dev/v2/mcp'); + expect(config).not.toContain('Authorization'); + expect(config).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + it('suppresses Hermes installer logs in quiet mode', async () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -540,126 +662,212 @@ describe('handleSetupCommand', () => { } }); - it('points every agent at the sign-in endpoint with --oauth', async () => { + 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'; - for (const dir of ['.claude', '.cursor', '.codex', '.hermes']) { - mkdirSync(path.join(sandboxHome, dir), { recursive: true }); - } - await handleSetupCommand('mcp', { oauth: true, yes: true } as never); + await installOpenClawMcp(); - const claude = readFileSync( - path.join(sandboxHome, '.claude.json'), - 'utf-8' + const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; + expect(config).toContain('Bearer ${FIRECRAWL_API_KEY}'); + expect(config).not.toContain('Bearer fc-test-key'); + }); + + it('honors explicit keyless setup for OpenClaw even when a key is stored', async () => { + await installMcp({ agent: 'openclaw', keyless: true }); + + const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; + expect(config).toContain('https://mcp.firecrawl.dev/v2/mcp'); + expect(config).not.toContain('Authorization'); + expect(config).not.toContain('fc-test-key'); + }); + + it('surfaces a sanitized OpenClaw setup failure', async () => { + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + vi.mocked(execFileSync).mockImplementationOnce(() => { + throw new Error('spawn failed with Authorization: Bearer fc-test-key'); + }); + + await expect(installOpenClawMcp()).rejects.toThrow( + 'Failed to configure Firecrawl MCP for OpenClaw. Verify that OpenClaw is installed and available on PATH.' ); - 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. + 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, '.codex', 'config.toml'), 'utf-8') - ).toContain('/v2/mcp-oauth'); + readFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), 'utf-8') + ).not.toContain('fc-test-key'); }); - 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('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'); - it('rejects that combination for an agent it only prints a URL for', async () => { - // This path returns early, so the check has to run ahead of it. - await expect( - handleSetupCommand('mcp', { - urlOnly: ['hermes'], - oauth: true, - keyless: true, - yes: true, - } as never) - ).rejects.toThrow(/either --oauth or --keyless/); + 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('prints the server URL for an agent it does not configure', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + 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 { - // Naming one is not an error, and nothing is written for it. await handleSetupCommand('mcp', { - urlOnly: ['hermes'], + clients: ['openclaw'], yes: true, + rules: true, } as never); - expect(log.mock.calls.flat().join(' ')).toContain(MCP_URL); - expect(existsSync(path.join(sandboxHome, '.hermes'))).toBe(false); + expect(readFileSync(path.join(moved, 'AGENTS.md'), 'utf-8')).toContain( + 'firecrawl_search' + ); } finally { - log.mockRestore(); + delete process.env.OPENCLAW_WORKSPACE_DIR; } }); - it('accepts those agents by name as well as by flag', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - try { - for (const agent of ['hermes', 'hermes-agent', 'openclaw']) { - await handleSetupCommand('mcp', { agent, yes: true }); - } - - expect(log.mock.calls.flat().join(' ')).toContain(MCP_URL); - expect(existsSync(path.join(sandboxHome, '.openclaw'))).toBe(false); - } finally { - log.mockRestore(); + 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 }); } - }); - it('configures the writers named beside an unwritten agent', async () => { - mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); + await handleSetupCommand('mcp', { oauth: true, yes: true } as never); - // --agent names one we do not configure; the flag names one we do. - await handleSetupCommand('mcp', { - agent: 'hermes', - clients: ['cursor'], - 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'); - expect(existsSync(globalConfigPath('cursor', sandboxHome))).toBe(true); + // 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('still configures the writers when both kinds are named', async () => { - mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); + 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: ['cursor'], - urlOnly: ['openclaw'], + clients: ['hermes', 'openclaw'], + oauth: true, yes: true, } as never); + // Hermes only starts the flow when the entry opts in. expect( - JSON.parse(readFileSync(globalConfigPath('cursor', sandboxHome), 'utf-8')) - .mcpServers.firecrawl.url - ).toBe(MCP_URL); + 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('sends the sign-in URL for an agent it does not configure', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + it('keeps credential configuration off the sign-in endpoint', async () => { + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - try { - await handleSetupCommand('mcp', { - urlOnly: ['openclaw'], + // 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); - - expect(log.mock.calls.flat().join(' ')).toContain(`${MCP_URL}-oauth`); - } finally { - log.mockRestore(); - } + } as never) + ).rejects.toThrow(/either --oauth or --keyless/); }); it('uses each client native environment binding with --agent all', async () => { @@ -695,8 +903,9 @@ describe('handleSetupCommand', () => { }); expect(codex).toContain('bearer_token_env_var = "FIRECRAWL_API_KEY"'); expect(`${claude}${cursor}${codex}`).not.toContain('fc-test-key'); - // `all` covers every agent setup writes for, and nothing else. - expect(existsSync(path.join(home, '.hermes', 'config.yaml'))).toBe(false); + expect( + readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') + ).toContain('Authorization: Bearer ${FIRECRAWL_API_KEY}'); } finally { rmSync(home, { recursive: true, force: true }); } @@ -713,9 +922,9 @@ describe('handleSetupCommand', () => { yes: true, }); - expect(readFileSync(globalConfigPath('cursor', home), 'utf-8')).toContain( - MCP_URL - ); + expect( + readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') + ).toContain(MCP_URL); } finally { rmSync(home, { recursive: true, force: true }); } @@ -807,6 +1016,45 @@ describe('handleSetupCommand', () => { } }); + it('does not print a stored OpenClaw credential when setup is rejected', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await expect(installOpenClawMcp()).rejects.toThrow( + 'Export FIRECRAWL_API_KEY' + ); + + expect(log.mock.calls.flat().join(' ')).not.toContain('fc-test-key'); + }); + + 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); + + try { + await handleSetupCommand('mcp', { + agent: 'claude-code', + global: true, + yes: true, + }); + + 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('writes MCP into global agent config', async () => { vi.mocked(getApiKey).mockReturnValue(undefined); const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-global-')); @@ -825,6 +1073,120 @@ describe('handleSetupCommand', () => { // --- Windows: launch .cmd/.exe shims correctly (execFileSync cannot) --- + 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, 'openclaw.CMD'), '@exit /b 0\r\n'); + const originalPlatform = Object.getOwnPropertyDescriptor( + process, + 'platform' + ); + const originalPath = process.env.PATH; + const originalPathext = process.env.PATHEXT; + const originalComspec = process.env.ComSpec; + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32', + }); + process.env.PATH = bin; + process.env.PATHEXT = '.EXE;.CMD'; + process.env.ComSpec = 'cmd.exe'; + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + + try { + await handleSetupCommand('mcp', { + agent: 'openclaw', + global: true, + yes: true, + }); + + const call = vi.mocked(execFileSync).mock.calls[0]; + const command = call?.[0] as string; + const passthruArgs = call?.[1] as string[]; + const opts = call?.[2] as { windowsVerbatimArguments?: boolean }; + + 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, '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); + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + if (originalPathext === undefined) delete process.env.PATHEXT; + else process.env.PATHEXT = originalPathext; + if (originalComspec === undefined) delete process.env.ComSpec; + else process.env.ComSpec = originalComspec; + rmSync(root, { recursive: true, force: true }); + } + }); + + it('launches a native executable directly on win32', async () => { + const bin = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-win-bin-')); + const openclawExe = path.join(bin, 'openclaw.EXE'); + writeFileSync(openclawExe, ''); + const originalPlatform = Object.getOwnPropertyDescriptor( + process, + 'platform' + ); + const originalPath = process.env.PATH; + const originalPathext = process.env.PATHEXT; + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32', + }); + process.env.PATH = bin; + process.env.PATHEXT = '.EXE;.CMD'; + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + + try { + await handleSetupCommand('mcp', { + agent: 'openclaw', + global: true, + yes: true, + }); + + const call = vi.mocked(execFileSync).mock.calls[0]; + const command = call?.[0] as string; + const args = call?.[1] as string[]; + const opts = call?.[2] as { windowsVerbatimArguments?: boolean }; + expect(command).toBe(openclawExe); + expect(args.join(' ')).toContain('Bearer ${FIRECRAWL_API_KEY}'); + expect(opts?.windowsVerbatimArguments).toBeUndefined(); + } finally { + if (originalPlatform) + Object.defineProperty(process, 'platform', originalPlatform); + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + if (originalPathext === undefined) delete process.env.PATHEXT; + else process.env.PATHEXT = originalPathext; + rmSync(bin, { recursive: true, force: true }); + } + }); + + it('still spawns bare argv with no shell on non-win32', async () => { + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + // Sanity: the POSIX path stays argv-safe with no shell interpolation. + await handleSetupCommand('mcp', { + agent: 'openclaw', + global: true, + yes: true, + }); + + const call = vi.mocked(execFileSync).mock.calls[0]; + expect(call?.[0]).toBe('openclaw'); + expect( + Array.isArray(call?.[1]) && (call?.[1] as string[]).length + ).toBeGreaterThan(0); + expect((call?.[2] as { shell?: boolean })?.shell).toBeUndefined(); + }); + it('strips inherited npm_* env vars before nested npx calls', async () => { // Reproduces the bug where running this CLI under `npx -y firecrawl-cli@VERSION` // leaks npm_command/npm_lifecycle_event/npm_execpath into nested diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 72cc2c4493..7de08e8da6 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -14,10 +14,12 @@ import { resolveMcpClientId, type McpContext, } from '../../utils/mcp-clients'; +import { parse as parseYaml } from 'yaml'; import { appendRuleSection, setupMcpClient, upsertTomlServer, + upsertYamlServer, writeJsonServerEntry, } from '../../utils/mcp-install'; @@ -312,6 +314,123 @@ describe('mcp install', () => { }); }); + 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); + }); + }); + describe('appendRuleSection', () => { it('keeps existing content and replaces only the fenced section', async () => { const file = path.join(root, 'AGENTS.md'); diff --git a/src/commands/launch.ts b/src/commands/launch.ts index 109689e376..e0b1bcc00f 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -4,7 +4,6 @@ import path from 'path'; import readline from 'readline'; import { spawnSync } from 'child_process'; import { installMcp, installSkillsForAgent } from './setup'; -import { resolveMcpUrlOnlyId } from '../utils/mcp-clients'; import { ALL_SKILL_REPOS } from './skills-install'; import { getApiKey } from '../utils/config'; @@ -250,11 +249,6 @@ export async function handleLaunchCommand( } const targetSupportsMcp = Boolean(target.mcpAgent); - // Some targets are supported without their MCP config being written; setup - // prints their server URL instead. Launch must not then claim otherwise. - const targetWritesMcp = Boolean( - target.mcpAgent && !resolveMcpUrlOnlyId(target.mcpAgent) - ); const targetSupportsSkills = Boolean(target.skillsAgent); let installMcpForTarget = targetSupportsMcp && !options.skipMcp; let installSkillsForTarget = targetSupportsSkills && !options.skipSkills; @@ -307,13 +301,7 @@ export async function handleLaunchCommand( } if (installOnly) { - // Only claim MCP when this run actually wrote it: --skip-mcp and the - // targets we do not configure both end up here having written none. - console.log( - installMcpForTarget && targetWritesMcp - ? `${target.displayName} is configured with Firecrawl MCP.` - : `${target.displayName} is set up.` - ); + console.log(`${target.displayName} is configured with Firecrawl MCP.`); return; } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 37d59099ed..9e79f79e06 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -3,7 +3,7 @@ * Installs firecrawl skill files and MCP server into AI coding agents */ -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import { existsSync } from 'fs'; import os from 'os'; import path from 'path'; @@ -28,32 +28,38 @@ import { 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, - MCP_URL_ONLY_IDS, - MCP_URL_ONLY_NAMES, - resolveMcpUrlOnlyId, - type McpClientId, - type McpUrlOnlyId, + type McpLauncherId, + type McpTargetId, } from '../utils/mcp-clients'; -import { setupMcpClient, type McpClientResult } from '../utils/mcp-install'; +import { + appendRuleSection, + setupMcpClient, + type McpClientResult, +} from '../utils/mcp-install'; export type SetupSubcommand = 'skills' | 'workflows' | 'mcp' | 'defaults'; type SetupIntegration = SetupSubcommand; type ResolvedMcpAgent = - | { kind: 'clients'; ids?: McpClientId[] } + | { kind: 'clients'; ids?: McpTargetId[] } + | { kind: 'launchers' } | { kind: 'skills-only'; agent: string } - /** Supported, but setup prints the URL instead of editing their config. */ - | { kind: 'url-only'; ids: McpUrlOnlyId[] } - | { kind: 'all' }; + | { kind: 'openclaw' } + | { kind: 'all-launchers' }; export interface SetupOptions { global?: boolean; @@ -70,9 +76,7 @@ export interface SetupOptions { /** Point agents at the sign-in endpoint instead of sending a credential. */ oauth?: boolean; /** Agents chosen by flag (`--claude`, `--cursor`, ...); skips the picker. */ - clients?: McpClientId[]; - /** Supported agents named by flag that setup does not configure. */ - urlOnly?: McpUrlOnlyId[]; + clients?: McpTargetId[]; /** Force the Firecrawl web rules on or off instead of prompting. */ rules?: boolean; } @@ -93,6 +97,108 @@ const SKILL_REPO_LABELS: Record = { 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(oauth = false): string { return oauth ? FIRECRAWL_MCP_OAUTH_URL : FIRECRAWL_MCP_URL; } @@ -104,6 +210,51 @@ function isEnvironmentBackedApiKey( return Boolean(apiKey && runtimeEnv[ENV_API_KEY] === apiKey); } +function assertSubprocessSafeCredential( + apiKey?: string, + runtimeEnv: NodeJS.ProcessEnv = process.env +): void { + if (apiKey && !isEnvironmentBackedApiKey(apiKey, runtimeEnv)) { + throw new Error( + 'Secure MCP setup cannot persist a stored API key for future client sessions. Export FIRECRAWL_API_KEY, launch the client through "firecrawl launch ", or configure keyless MCP.' + ); + } +} + +function environmentHeaderForAgent(agent?: string): string | undefined { + switch (agent) { + case 'claude-code': + case 'hermes': + case 'openclaw': + return `Bearer \${${ENV_API_KEY}}`; + case 'cursor': + case 'vscode': + return `Bearer \${env:${ENV_API_KEY}}`; + case 'opencode': + return `Bearer {env:${ENV_API_KEY}}`; + default: + return undefined; + } +} + +function firecrawlMcpHeaders( + agent?: string, + apiKey?: string, + runtimeEnv: NodeJS.ProcessEnv = process.env +): Record | undefined { + if (!apiKey) return undefined; + + // Keep this helper safe in isolation. Callers currently reject stored keys + // before reaching it, but a future call site must not turn one into a raw + // Authorization header in argv or a client configuration file. + assertSubprocessSafeCredential(apiKey, runtimeEnv); + const environmentHeader = environmentHeaderForAgent(agent); + if (environmentHeader) return { Authorization: environmentHeader }; + throw new Error( + 'This MCP client does not have a verified environment-variable syntax. Choose a supported --agent, use --agent all, or configure the client manually so FIRECRAWL_API_KEY is not persisted as a literal.' + ); +} + function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { if (!agent) return { kind: 'clients' }; @@ -111,19 +262,22 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { switch (normalized) { case '*': case 'all': - return { kind: 'all' }; + return { kind: 'all-launchers' }; + case 'launchers': + case 'launcher': + return { kind: 'launchers' }; + case 'openclaw': + return { kind: 'openclaw' }; default: { const id = resolveMcpClientId(normalized); if (id) return { kind: 'clients', ids: [id] }; - const urlOnly = resolveMcpUrlOnlyId(normalized); - if (urlOnly) return { kind: 'url-only', ids: [urlOnly] }; // 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_CLIENT_IDS, ...MCP_URL_ONLY_IDS].join(', ')}, all.` + `Unknown agent "${agent}" for setup mcp. Use one of: ${ALL_MCP_TARGET_IDS.join(', ')}, all.` ); } } @@ -390,27 +544,6 @@ export async function installSkillsForAgent( ); } -/** The endpoint this run points agents at, which sign-in changes. */ -function mcpUrlFor(options: SetupOptions): string { - return options.oauth ? FIRECRAWL_MCP_OAUTH_URL : FIRECRAWL_MCP_URL; -} - -/** - * Report the agents Firecrawl supports but does not configure. Naming one is - * not an error: the run succeeds and prints the URL so the person can point - * the agent at it themselves. - */ -function reportUrlOnly(ids: McpUrlOnlyId[], options: SetupOptions): void { - // Quiet mode is embedded in init and launch, which indent every line they - // render, so these sit with the rest of that output rather than beside it. - const indent = options.quiet ? ' ' : ''; - for (const id of ids) { - console.log( - `${indent}${MCP_URL_ONLY_NAMES[id]}: Firecrawl does not write its MCP config. Point it at ${mcpUrlFor(options)} to connect it yourself.` - ); - } -} - export async function installMcp( options: SetupOptions, // `firecrawl launch` may provide the exact environment inherited by the @@ -418,53 +551,51 @@ export async function installMcp( // without mutating the parent shell or exposing the key to setup commands. runtimeEnv: NodeJS.ProcessEnv = process.env ): Promise { - // Checked before anything else reports or returns, so an agent we only print - // a URL for cannot accept a combination the writers reject. + // 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); + // 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 ${mcpUrlFor(options)} to connect it yourself.` + `Firecrawl does not write MCP config for ${resolvedAgent.agent}. Point it at ${FIRECRAWL_MCP_URL} to connect it yourself.` ); return; } - if (resolvedAgent.kind === 'all') { - // `all` covers the agents we do not configure too, and their URL is the - // whole answer for them. A writer failing must not swallow it, so the - // report runs before the failure leaves this function. - try { - await installMcpClients({ ...options, yes: true }, runtimeEnv, [ - ...ALL_MCP_CLIENT_IDS, - ]); - } finally { - reportUrlOnly([...MCP_URL_ONLY_IDS], options); - } + if (resolvedAgent.kind === 'openclaw') { + // Routed through the same reporter as every other target so the keyless + // fallback is stated rather than implied by a bare installer log line. + await installMcpClients({ ...options, yes: true }, runtimeEnv, [ + resolvedAgent.kind, + ]); + return; + } + if (resolvedAgent.kind === 'launchers') { + await installMcpClients({ ...options, yes: true }, runtimeEnv, [ + ...ALL_MCP_LAUNCHER_IDS, + ]); + return; + } + if (resolvedAgent.kind === 'all-launchers') { + await installMcpClients({ ...options, yes: true }, runtimeEnv, undefined, { + includeAllLaunchers: true, + }); return; } - // Every agent named this run that setup does not configure, whichever flag - // form named it. Reported once, and never in place of the rest of the run. - const urlOnly = [ - ...(options.urlOnly ?? []), - ...(resolvedAgent.kind === 'url-only' ? resolvedAgent.ids : []), - ].filter((id, index, all) => all.indexOf(id) === index); - if (urlOnly.length > 0) reportUrlOnly(urlOnly, options); - - const explicitIds = - resolvedAgent.kind === 'clients' ? resolvedAgent.ids : undefined; - // The URL was the whole request only when nothing else was named. - if (urlOnly.length > 0 && !explicitIds && !options.clients?.length) return; - - await installMcpClients(options, runtimeEnv, explicitIds); + await installMcpClients(options, runtimeEnv, resolvedAgent.ids); } /** Shorten a path for display: relative inside the project, `~` under home. */ @@ -480,10 +611,10 @@ function displayPath(target: string, ctx: McpContext): string { } async function pickMcpClients( - detected: readonly McpClientId[] -): Promise { + detected: readonly McpTargetId[] +): Promise { const { checkbox } = await import('@inquirer/prompts'); - return checkbox({ + return checkbox({ message: 'Which agents do you want to set up?', loop: false, pageSize: detected.length, @@ -501,6 +632,104 @@ async function pickMcpClients( * out of reach here, so the launcher itself is the authority. Falls back to the * documented defaults whenever the CLI cannot answer. */ +function openclawConfiguredWorkspace( + runtimeEnv: NodeJS.ProcessEnv, + id: McpLauncherId +): string | undefined { + if (id !== 'openclaw') return undefined; + try { + const stdout = execFileSync( + 'openclaw', + ['config', 'get', 'agents.defaults.workspace', '--json'], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + env: cleanNpmEnv(), + } + ); + const value: unknown = JSON.parse(stdout); + if (typeof value !== 'string' || value === '') return undefined; + const expanded = value.startsWith('~') + ? path.join(os.homedir(), value.slice(1)) + : value; + return path.join(expanded, 'AGENTS.md'); + } catch { + return undefined; + } +} + +/** + * 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: '', + auth: keyless ? 'keyless' : 'env', + ruleStatus: 'unsupported', + ruleDetail: '', + }; + + 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); + } + + const rule = MCP_LAUNCHER_RULES[id]; + if (!rule) return result; + if (!rules) { + // The launcher does take rules; the run just did not ask for them. + result.ruleStatus = 'skipped'; + return result; + } + + const rulePath = + openclawConfiguredWorkspace(runtimeEnv, 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; + } + + try { + result.ruleStatus = await appendRuleSection(rulePath, rule.content); + result.ruleDetail = rulePath; + } 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({ @@ -513,7 +742,7 @@ async function confirmMcpRules(): Promise { async function installMcpClients( options: SetupOptions, runtimeEnv: NodeJS.ProcessEnv, - explicitIds?: McpClientId[], + explicitIds?: McpTargetId[], { includeAllLaunchers = false } = {} ): Promise { const apiKey = options.oauth || options.keyless ? undefined : getApiKey(); @@ -538,12 +767,17 @@ async function installMcpClients( // Prompts only make sense when someone is there to answer them. const nonInteractive = Boolean(options.yes) || !process.stdin.isTTY; - let selected = explicitIds ?? options.clients; + let selected = includeAllLaunchers + ? [...ALL_MCP_CLIENT_IDS] + : (explicitIds ?? options.clients); if (!selected || selected.length === 0) { - const detected: McpClientId[] = await detectMcpClients(ctx); - if (detected.length === 0) { + const detected: McpTargetId[] = [ + ...(await detectMcpClients(ctx)), + ...detectMcpLaunchers(ctx), + ]; + if (detected.length === 0 && !includeAllLaunchers) { throw new Error( - `No coding agents detected. Pass an agent flag such as --claude or --cursor, or point one at ${mcpUrlFor(options)} yourself.` + 'No coding agents detected. Pass an agent flag such as --claude or --cursor.' ); } if (nonInteractive) { @@ -557,6 +791,15 @@ async function installMcpClients( } } + // `--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, + ]; + } + // `-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 = @@ -564,7 +807,11 @@ async function installMcpClients( const results: McpClientResult[] = []; for (const id of selected) { - results.push(await setupMcpClient(id, { rules, ctx })); + results.push( + isMcpLauncherId(id) + ? await setupMcpLauncher(id, ctx, runtimeEnv, rules) + : await setupMcpClient(id, { rules, ctx }) + ); } reportMcpResults(results, ctx, options, Boolean(apiKey)); @@ -630,7 +877,9 @@ function signInLine( ctx: McpContext ): string | undefined { if (ctx.auth !== 'oauth' || result.mcpStatus === 'failed') return undefined; - const spec = MCP_CLIENTS[result.id].oauth; + const spec = isMcpLauncherId(result.id) + ? MCP_LAUNCHER_OAUTH[result.id] + : MCP_CLIENTS[result.id].oauth; return spec ? ` Sign in ${dim}${spec.nextStep}${reset}` : undefined; } @@ -690,3 +939,57 @@ function reportMcpResults( ); } } + +function firecrawlMcpConfig( + agent?: string, + runtimeEnv: NodeJS.ProcessEnv = process.env, + keyless = false, + oauth = false +): { + url: string; + headers?: Record; + transport?: string; +} { + return { + 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 || oauth ? undefined : getApiKey(), + runtimeEnv + ), + }; +} + +export async function installOpenClawMcp( + runtimeEnv: NodeJS.ProcessEnv = process.env, + keyless = false, + /** Suppress standalone logging when a caller renders its own summary. */ + quiet = false, + oauth = false +): Promise { + const config = { + ...firecrawlMcpConfig('openclaw', runtimeEnv, keyless, oauth), + transport: 'streamable-http', + ...(oauth ? MCP_LAUNCHER_OAUTH.openclaw?.entry : undefined), + }; + if (!quiet) console.log('Configuring Firecrawl MCP for OpenClaw...\n'); + + try { + runClientCommand( + 'openclaw', + ['mcp', 'set', 'firecrawl', JSON.stringify(config)], + { + stdio: 'pipe', + env: cleanNpmEnv(), + } + ); + } catch { + throw new Error( + 'Failed to configure Firecrawl MCP for OpenClaw. Verify that OpenClaw is installed and available on PATH.' + ); + } +} diff --git a/src/index.ts b/src/index.ts index 576ff10a0a..aa8ff14fc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,12 +60,7 @@ import { } from './commands/init'; import { handleMakeDefaultCommand, handleSetupCommand } from './commands/setup'; import type { SetupSubcommand } from './commands/setup'; -import { - ALL_MCP_TARGET_IDS, - MCP_URL_ONLY_IDS, - MCP_URL_ONLY_NAMES, - mcpTargetName, -} from './utils/mcp-clients'; +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'; @@ -2269,14 +2264,6 @@ const setupCommand = program for (const id of ALL_MCP_TARGET_IDS) { setupCommand.option(`--${id}`, `Set up ${mcpTargetName(id)} (mcp)`); } -// Supported agents we do not configure still take a flag, so naming one -// succeeds with the server URL instead of failing as unknown. -for (const id of MCP_URL_ONLY_IDS) { - setupCommand.option( - `--${id}`, - `Show the MCP URL for ${MCP_URL_ONLY_NAMES[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. @@ -2307,7 +2294,6 @@ Examples: await handleSetupCommand(subcommand, { ...options, clients: ALL_MCP_TARGET_IDS.filter((id) => options[id] === true), - urlOnly: MCP_URL_ONLY_IDS.filter((id) => options[id] === true), }); }); diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index e4e8ea9758..13b45f7a7d 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -24,35 +24,25 @@ 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'; +export type McpClientId = + | 'claude' + | 'cursor' + | 'vscode' + | 'codex' + | 'opencode' + | 'hermes'; /** - * Agents Firecrawl supports without configuring. Setup writes a global entry - * to a file it can parse, never a literal key, plus an optional rule file it - * owns. These agents do not share that contract: each needs its own writer, - * its own credential shape, or a subprocess. Setup prints the server URL for - * them instead, and skills and `firecrawl launch` are unaffected. + * 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 const MCP_URL_ONLY_IDS = ['hermes', 'openclaw'] as const; -export type McpUrlOnlyId = (typeof MCP_URL_ONLY_IDS)[number]; - -export const MCP_URL_ONLY_NAMES: Record = { - hermes: 'Hermes Agent', - openclaw: 'OpenClaw', -}; +export type McpLauncherId = 'openclaw'; -const URL_ONLY_ALIASES: Record = { - hermes: 'hermes', - 'hermes-agent': 'hermes', - openclaw: 'openclaw', -}; - -export function resolveMcpUrlOnlyId(agent: string): McpUrlOnlyId | undefined { - const alias = agent.trim().toLowerCase(); - return Object.prototype.hasOwnProperty.call(URL_ONLY_ALIASES, alias) - ? URL_ONLY_ALIASES[alias] - : undefined; -} +export type McpTargetId = McpClientId | McpLauncherId; /** * `env` writes an indirect reference to `FIRECRAWL_API_KEY`, which only works @@ -97,10 +87,15 @@ export interface McpOauthSpec { export interface McpClient { id: McpClientId; name: string; - format: 'json' | 'toml'; + 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; @@ -299,6 +294,28 @@ export const MCP_CLIENTS: Record = { oauth: { nextStep: 'OpenCode opens the browser on first use' }, detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], }, + hermes: { + id: 'hermes', + name: 'Hermes Agent', + format: 'yaml', + serversKey: 'mcp_servers', + globalConfigPath: (ctx) => path.join(ctx.home, '.hermes', '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. + oauth: { + entry: { auth: 'oauth' }, + nextStep: 'Hermes opens the browser on first use', + }, + detectPaths: (ctx) => [path.join(ctx.home, '.hermes')], + }, }; export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ @@ -307,12 +324,105 @@ export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ 'vscode', 'codex', 'opencode', + 'hermes', ]; -export const ALL_MCP_TARGET_IDS: readonly McpClientId[] = ALL_MCP_CLIENT_IDS; +export const MCP_LAUNCHER_NAMES: Record = { + openclaw: 'OpenClaw', +}; -export function mcpTargetName(id: McpClientId): string { - return MCP_CLIENTS[id].name; +/** + * 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: 'run 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. + */ +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 (existsSync(path.join(entry, `${name}${extension}`))) 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. */ @@ -330,6 +440,8 @@ const CLIENT_ALIASES: Record = { 'codex-gui': 'codex', opencode: 'opencode', 'open-code': 'opencode', + hermes: 'hermes', + 'hermes-agent': 'hermes', }; export function resolveMcpClientId(agent: string): McpClientId | undefined { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index bd78361d7f..252263f4ad 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -12,6 +12,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser'; +import { parseDocument } from 'yaml'; import { MCP_CLIENTS, MCP_SERVER_NAME, @@ -20,6 +21,7 @@ import { type McpClient, type McpClientId, type McpContext, + type McpTargetId, } from './mcp-clients'; export type McpStatus = 'configured' | 'reconfigured' | 'failed'; @@ -31,7 +33,7 @@ export type RuleStatus = | 'failed'; export interface McpClientResult { - id: McpClientId; + id: McpTargetId; name: string; mcpStatus: McpStatus; /** Config path on success, error message on failure. */ @@ -58,10 +60,12 @@ async function readIfExists(filePath: string): Promise { async function writeFileEnsuringDir( filePath: string, - content: 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, 'utf8'); + await fs.writeFile(filePath, content, { encoding: 'utf8', mode: createMode }); } function escapeRegExp(value: string): string { @@ -129,6 +133,50 @@ export async function writeJsonServerEntry( 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]); + // 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. + if (doc.getIn([serversKey]) === null) { + const empty = doc.getIn([serversKey], true) 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); + } + 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, + }; +} + /** * True when the character at `index` is escaped. Backslashes escape each other, * so only an odd run of them before the position leaves it escaped. @@ -339,6 +387,29 @@ async function writeMcpEntry( ? { ...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 = {}; From 5893c9c1b9c89734df7f91908e5b86967cb3902c Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Fri, 14 Aug 2026 11:14:05 -0700 Subject: [PATCH 25/35] fix(cli): ask about rules for --agent all, launchers, and openclaw Those three branches passed `yes: true` into installMcpClients to skip the picker, but naming the targets already does that: the picker only runs when no targets were selected, and each of these arrives with a list. What `yes` still reached was the rules prompt, so `--agent cursor` asked whether to install rules and `--agent all` silently decided no. Dropping the flag leaves the picker skipped as before and lets the question be asked. Nothing else moves: without a TTY the run is still non-interactive, `-y` still declines rules, and `--rules` still forces them. --- src/__tests__/commands/setup.test.ts | 28 ++++++++++++++++++++++++++++ src/commands/setup.ts | 12 +++++------- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 4be5144f2f..49c8f6577b 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -524,6 +524,34 @@ describe('handleSetupCommand', () => { 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 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( + existsSync(path.join(sandboxHome, '.cursor', 'rules', 'firecrawl.mdc')) + ).toBe(true); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: originalIsTTY, + }); + } + }); + it('surfaces total failure even in quiet mode', async () => { mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); writeFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), '{ broken'); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 9e79f79e06..7558e1a114 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -577,19 +577,15 @@ export async function installMcp( if (resolvedAgent.kind === 'openclaw') { // Routed through the same reporter as every other target so the keyless // fallback is stated rather than implied by a bare installer log line. - await installMcpClients({ ...options, yes: true }, runtimeEnv, [ - resolvedAgent.kind, - ]); + await installMcpClients(options, runtimeEnv, [resolvedAgent.kind]); return; } if (resolvedAgent.kind === 'launchers') { - await installMcpClients({ ...options, yes: true }, runtimeEnv, [ - ...ALL_MCP_LAUNCHER_IDS, - ]); + await installMcpClients(options, runtimeEnv, [...ALL_MCP_LAUNCHER_IDS]); return; } if (resolvedAgent.kind === 'all-launchers') { - await installMcpClients({ ...options, yes: true }, runtimeEnv, undefined, { + await installMcpClients(options, runtimeEnv, undefined, { includeAllLaunchers: true, }); return; @@ -767,6 +763,8 @@ async function installMcpClients( // 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); From 539f1f1ec4d14ffc04a83644c6351fea743797d2 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Fri, 14 Aug 2026 11:43:32 -0700 Subject: [PATCH 26/35] fix(cli): skip OpenClaw rules when MCP fails and close restored launcher gaps The Hermes/OpenClaw revert brought back unguarded launcher rule writes, PATH false positives, a Windows workspace probe that swallowed .cmd failures, and YAML setIn crashes on non-mapping mcp_servers. --- src/__tests__/commands/launch.test.ts | 21 ++++++++ src/__tests__/commands/setup.test.ts | 69 +++++++++++++++++++++++++ src/__tests__/utils/mcp-install.test.ts | 47 +++++++++++++++++ src/commands/launch.ts | 6 ++- src/commands/setup.ts | 22 ++++---- src/utils/mcp-clients.ts | 27 +++++++++- src/utils/mcp-install.ts | 13 +++-- 7 files changed, 188 insertions(+), 17 deletions(-) diff --git a/src/__tests__/commands/launch.test.ts b/src/__tests__/commands/launch.test.ts index 600ddcfc71..b62e76f121 100644 --- a/src/__tests__/commands/launch.test.ts +++ b/src/__tests__/commands/launch.test.ts @@ -317,6 +317,27 @@ describe('handleLaunchCommand', () => { expect(installSkillsForAgent).not.toHaveBeenCalled(); }); + it('does not claim MCP was configured when install mode skipped it', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await handleLaunchCommand('opencode', { + install: true, + skipMcp: true, + skipSkills: true, + }); + + expect(installMcp).not.toHaveBeenCalled(); + expect(installSkillsForAgent).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalledWith( + expect.stringContaining('configured with Firecrawl MCP') + ); + } finally { + log.mockRestore(); + } + }); + 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 49c8f6577b..ee7376e6c0 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -65,6 +65,8 @@ describe('handleSetupCommand', () => { let originalPath: string | undefined; let originalUserProfile: string | undefined; let originalAppData: string | undefined; + let originalOpenclawWorkspace: string | undefined; + let originalOpenclawProfile: string | undefined; let originalCwd: string; let sandboxCwd: string; @@ -96,6 +98,10 @@ describe('handleSetupCommand', () => { // 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; + delete process.env.OPENCLAW_WORKSPACE_DIR; + delete process.env.OPENCLAW_PROFILE; }); afterEach(() => { @@ -104,6 +110,16 @@ describe('handleSetupCommand', () => { 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 (originalUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = originalUserProfile; if (originalAppData === undefined) delete process.env.APPDATA; @@ -790,6 +806,59 @@ describe('handleSetupCommand', () => { 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', { + clients: ['openclaw'], + yes: true, + rules: true, + } as never) + ).rejects.toThrow(/OpenClaw/); + + expect(readFileSync(agentsFile, 'utf-8')).toBe('# Keep\n'); + }); + + 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('leaves the OpenClaw rule alone until its workspace exists', async () => { await handleSetupCommand('mcp', { clients: ['openclaw'], diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 7de08e8da6..1a1bbc2bec 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + chmodSync, existsSync, mkdtempSync, mkdirSync, @@ -11,6 +12,7 @@ import os from 'os'; import path from 'path'; import { detectMcpClients, + detectMcpLaunchers, resolveMcpClientId, type McpContext, } from '../../utils/mcp-clients'; @@ -429,6 +431,19 @@ describe('mcp install', () => { ) ).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', () => { @@ -616,6 +631,38 @@ describe('mcp install', () => { }); }); + 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'); diff --git a/src/commands/launch.ts b/src/commands/launch.ts index e0b1bcc00f..f17e5c277e 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -301,7 +301,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 7558e1a114..c4ab8f03fa 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -173,21 +173,19 @@ function runClientCommand( command: string, args: string[], options: Parameters[2] -): void { +): ReturnType { rejectCommandControlCharacters(command, 'Command'); for (const arg of args) rejectCommandControlCharacters(arg, 'Command argument'); if (process.platform !== 'win32') { - execFileSync(command, args, options); - return; + return execFileSync(command, args, options); } const env = options?.env ?? process.env; const resolved = resolveWindowsCommand(command, env); if (!/\.(?:cmd|bat)$/i.test(resolved)) { - execFileSync(resolved, args, options); - return; + return execFileSync(resolved, args, options); } const line = [escapeCmdArg(resolved), ...args.map(escapeCmdArg)].join(' '); @@ -196,7 +194,7 @@ function runClientCommand( ...options, windowsVerbatimArguments: true, } as Parameters[2]; - execFileSync(comspec, ['/d', '/s', '/c', `"${line}"`], windowsOptions); + return execFileSync(comspec, ['/d', '/s', '/c', `"${line}"`], windowsOptions); } function firecrawlHostedMcpUrl(oauth = false): string { @@ -634,7 +632,10 @@ function openclawConfiguredWorkspace( ): string | undefined { if (id !== 'openclaw') return undefined; try { - const stdout = execFileSync( + // 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'], { @@ -643,7 +644,7 @@ function openclawConfiguredWorkspace( env: cleanNpmEnv(), } ); - const value: unknown = JSON.parse(stdout); + const value: unknown = JSON.parse(String(stdout)); if (typeof value !== 'string' || value === '') return undefined; const expanded = value.startsWith('~') ? path.join(os.homedir(), value.slice(1)) @@ -699,8 +700,9 @@ async function setupMcpLauncher( const rule = MCP_LAUNCHER_RULES[id]; if (!rule) return result; - if (!rules) { - // The launcher does take rules; the run just did not ask for them. + 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; } diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 13b45f7a7d..bbd229051d 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -12,7 +12,13 @@ * expand. An agent is only supported once that syntax is verified. */ -import { existsSync, promises as fs } from 'fs'; +import { + accessSync, + constants as fsConstants, + existsSync, + promises as fs, + statSync, +} from 'fs'; import path from 'path'; export const FIRECRAWL_MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; @@ -388,7 +394,23 @@ export function mcpTargetName(id: McpTargetId): string { * 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' @@ -399,7 +421,8 @@ function binaryOnPath(name: string, ctx: McpContext): boolean { .filter(Boolean); for (const entry of entries) { for (const extension of extensions) { - if (existsSync(path.join(entry, `${name}${extension}`))) return true; + if (isRunnablePath(path.join(entry, `${name}${extension}`), ctx.platform)) + return true; } } return false; diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 252263f4ad..6959ae2c77 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -12,7 +12,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser'; -import { parseDocument } from 'yaml'; +import { isMap, isScalar, parseDocument } from 'yaml'; import { MCP_CLIENTS, MCP_SERVER_NAME, @@ -151,18 +151,23 @@ export function upsertYamlServer( } 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. - if (doc.getIn([serversKey]) === null) { - const empty = doc.getIn([serversKey], true) as { comment?: string | null }; + // 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); From 0cda80b8db3de37d4b32a40b6ca159715d6cb418 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Fri, 14 Aug 2026 12:16:14 -0700 Subject: [PATCH 27/35] fix(cli): print a sign-in step only where an agent needs one The oauth report prefixed every step with "Sign in" and then repeated the verb inside the step itself, so VS Code read "Sign in sign in from the MCP view in VS Code". Checking each agent against its own docs showed the duplication was hiding worse problems: VS Code has no MCP view and runs client registration itself, Cursor surfaces a needs-login control of its own, OpenCode prompts on first use, and Claude Code shows a startup notice for any server that answers 401, which the sign-in endpoint does. Those four now print nothing. The three that do need a command keep one, and the strings are corrected: Codex leads with its own command but names the Authenticate action, since the desktop app and the IDE extension share the config file this writes and only the CLI needs the command. Hermes reloads config on a 30s timer that cannot outlast an interactive flow, so it names the login command and says to run it from a new terminal rather than claiming the browser opens by itself. The footer no longer promises that every agent prompts you. --- src/commands/setup.ts | 16 +++++++++------- src/utils/mcp-clients.ts | 34 +++++++++++++++++++++++----------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/commands/setup.ts b/src/commands/setup.ts index c4ab8f03fa..7bdc82b6eb 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -847,9 +847,9 @@ function authNotes( if (succeeded.length === 0) return []; if (ctx.auth === 'oauth') { - return [ - 'Each agent signs in through your browser the first time it connects.', - ]; + // 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) { @@ -868,9 +868,9 @@ function authNotes( } /** - * What the person still has to do for this agent. Setup can register the - * server but no agent signs in on its behalf, and each one starts the flow - * differently, so a single footer would leave most agents unexplained. + * 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, @@ -880,7 +880,9 @@ function signInLine( const spec = isMcpLauncherId(result.id) ? MCP_LAUNCHER_OAUTH[result.id] : MCP_CLIENTS[result.id].oauth; - return spec ? ` Sign in ${dim}${spec.nextStep}${reset}` : undefined; + return spec?.nextStep + ? ` Sign in: ${dim}${spec.nextStep}${reset}` + : undefined; } function reportMcpResults( diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index bbd229051d..9f4fe42a6b 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -86,8 +86,12 @@ export interface McpRuleSpec { export interface McpOauthSpec { /** Entry fields the agent needs before it will start the flow. */ entry?: Record; - /** What the person does next, since no agent signs in during setup. */ - nextStep: string; + /** + * 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 { @@ -211,7 +215,8 @@ export const MCP_CLIENTS: Record = { globalPath: (ctx) => path.join(claudeConfigDir(ctx), 'rules', 'firecrawl.md'), }, - oauth: { nextStep: 'run /mcp in Claude Code to sign in' }, + // 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: { @@ -228,7 +233,7 @@ export const MCP_CLIENTS: Record = { globalPath: (ctx) => path.join(ctx.home, '.cursor', 'rules', 'firecrawl.mdc'), }, - oauth: { nextStep: 'open Cursor Settings, select MCP, and sign in' }, + // Cursor marks the server as needing login in its own MCP settings. detectPaths: (ctx) => [path.join(ctx.home, '.cursor')], }, vscode: { @@ -251,7 +256,8 @@ export const MCP_CLIENTS: Record = { }, // `User` is created on first launch, so requiring it misses an install // that has only been unpacked. These are the markers doctor already uses. - oauth: { nextStep: 'sign in from the MCP view in VS Code' }, + // 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'), @@ -274,8 +280,12 @@ export const MCP_CLIENTS: Record = { content: RULE_BODY, globalPath: (ctx) => path.join(ctx.home, '.codex', 'AGENTS.md'), }, - // Codex registers the server but does not start the flow on its own. - oauth: { nextStep: 'run codex mcp login firecrawl' }, + // 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) => [path.join(ctx.home, '.codex')], }, opencode: { @@ -297,7 +307,7 @@ export const MCP_CLIENTS: Record = { globalPath: (ctx) => path.join(ctx.home, '.config', 'opencode', 'AGENTS.md'), }, - oauth: { nextStep: 'OpenCode opens the browser on first use' }, + // OpenCode prompts on first use, so there is nothing to tell the user. detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], }, hermes: { @@ -315,10 +325,12 @@ export const MCP_CLIENTS: Record = { 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. + // 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 opens the browser on first use', + nextStep: 'hermes mcp login firecrawl, from a new terminal', }, detectPaths: (ctx) => [path.join(ctx.home, '.hermes')], }, @@ -363,7 +375,7 @@ export const MCP_LAUNCHER_OAUTH: Partial> = // 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: 'run openclaw mcp login firecrawl', + nextStep: 'openclaw mcp login firecrawl', }, }; From d4191e6f3cd4458f81e75f60afbfe25c7d9fa5f8 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Fri, 14 Aug 2026 12:18:03 -0700 Subject: [PATCH 28/35] fix(cli): drop the rules line for agents that have no rules mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes has no global rules file to own, so every run ended its block with "Rules not supported by this agent" — a line about something the user never asked for and cannot act on. The skipped case still prints, because that one means a rule was requested and did not land. --- src/commands/setup.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 7bdc82b6eb..15351d8353 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -828,7 +828,9 @@ function ruleLine( case 'skipped': return ' Rules skipped'; case 'unsupported': - return ` Rules ${dim}not supported by this agent${reset}`; + // Nothing was asked for and nothing can be done about it, so saying so + // every run is noise. `skipped` still prints: that one was asked for. + return undefined; case 'failed': return ` ${red}Rules failed${reset} ${result.ruleDetail}`; } From a89cb864d1cd72ab374c7ced6bbae4b5c8dccad1 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Fri, 14 Aug 2026 12:24:22 -0700 Subject: [PATCH 29/35] fix(cli): stop init from advertising MCP setup it just ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integrations checkbox named three editors, but MCP setup writes to every agent it detects, so the list both undersold it and went stale as agents were added. It now reads like the skills entry above it and names no agents. printNextSteps also offered "Add MCP: firecrawl setup mcp" unconditionally, including right below its own "✓ MCP server installed". stepIntegrations now reports whether the install succeeded, and the line prints only for someone who does not have it: skipping the integration or a failed install both keep it. --- src/commands/init.ts | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 144bcfd848..d93fd3fafd 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': { @@ -616,6 +633,7 @@ async function stepIntegrations(options: InitOptions): Promise { keyless: !environmentBacked, }); console.log(` ${green}✓${reset} MCP server installed`); + mcpInstalled = true; } catch (error) { const message = error instanceof Error @@ -640,7 +658,7 @@ async function stepIntegrations(options: InitOptions): Promise { } } } - return totalSkills; + return { skillCount: totalSkills, mcpInstalled }; } /** @@ -944,8 +962,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 +973,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 { From 98ed45f8a9975261b1356d1d0bfc842718833f7d Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Fri, 14 Aug 2026 12:41:58 -0700 Subject: [PATCH 30/35] fix(cli): tell quiet callers when only some agents were configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quiet mode fails only when nothing lands, so a run where one agent failed resolved normally and init printed "✓ MCP server installed" directly under its own ✗ line, then hid the "Add MCP" next step that would have fixed it. installMcp now returns whether every targeted agent was configured, and init claims success only on that; a partial run points at "firecrawl setup mcp" and keeps the next step. The OpenClaw workspace lookup also expanded any leading tilde against home, so a `~other/ws` workspace, which names another account to a shell, wrote the rule into $HOME/other/ws instead. Expansion is now limited to `~` and a `~/` prefix, and reads the resolved home from the context the writers already carry rather than calling os.homedir() again. --- src/__tests__/commands/setup.test.ts | 73 ++++++++++++++++++++++++++++ src/commands/init.ts | 12 +++-- src/commands/setup.ts | 49 ++++++++++++------- 3 files changed, 114 insertions(+), 20 deletions(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index ee7376e6c0..5558343319 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -579,6 +579,31 @@ describe('handleSetupCommand', () => { ).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', @@ -859,6 +884,54 @@ describe('handleSetupCommand', () => { 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'], diff --git a/src/commands/init.ts b/src/commands/init.ts index d93fd3fafd..db5e9c3d60 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -622,7 +622,10 @@ async function stepIntegrations( 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), yes: true, @@ -632,8 +635,11 @@ async function stepIntegrations( // credential continues through the authenticated setup path. keyless: !environmentBacked, }); - console.log(` ${green}✓${reset} MCP server installed`); - mcpInstalled = true; + 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 diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 15351d8353..68d25ab469 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -548,7 +548,10 @@ 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 { + // 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) { @@ -569,27 +572,24 @@ export async function installMcp( console.log( `Firecrawl does not write MCP config for ${resolvedAgent.agent}. Point it at ${FIRECRAWL_MCP_URL} to connect it yourself.` ); - return; + return false; } if (resolvedAgent.kind === 'openclaw') { // Routed through the same reporter as every other target so the keyless // fallback is stated rather than implied by a bare installer log line. - await installMcpClients(options, runtimeEnv, [resolvedAgent.kind]); - return; + return installMcpClients(options, runtimeEnv, [resolvedAgent.kind]); } if (resolvedAgent.kind === 'launchers') { - await installMcpClients(options, runtimeEnv, [...ALL_MCP_LAUNCHER_IDS]); - return; + return installMcpClients(options, runtimeEnv, [...ALL_MCP_LAUNCHER_IDS]); } if (resolvedAgent.kind === 'all-launchers') { - await installMcpClients(options, runtimeEnv, undefined, { + return installMcpClients(options, runtimeEnv, undefined, { includeAllLaunchers: true, }); - return; } - await installMcpClients(options, runtimeEnv, resolvedAgent.ids); + return installMcpClients(options, runtimeEnv, resolvedAgent.ids); } /** Shorten a path for display: relative inside the project, `~` under home. */ @@ -620,6 +620,22 @@ async function pickMcpClients( }); } +/** + * 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 @@ -627,7 +643,7 @@ async function pickMcpClients( * documented defaults whenever the CLI cannot answer. */ function openclawConfiguredWorkspace( - runtimeEnv: NodeJS.ProcessEnv, + ctx: McpContext, id: McpLauncherId ): string | undefined { if (id !== 'openclaw') return undefined; @@ -646,9 +662,8 @@ function openclawConfiguredWorkspace( ); const value: unknown = JSON.parse(String(stdout)); if (typeof value !== 'string' || value === '') return undefined; - const expanded = value.startsWith('~') - ? path.join(os.homedir(), value.slice(1)) - : value; + 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; @@ -707,8 +722,7 @@ async function setupMcpLauncher( return result; } - const rulePath = - openclawConfiguredWorkspace(runtimeEnv, id) ?? rule.globalPath(ctx); + 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. @@ -742,7 +756,7 @@ async function installMcpClients( runtimeEnv: NodeJS.ProcessEnv, explicitIds?: McpTargetId[], { includeAllLaunchers = false } = {} -): Promise { +): 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 @@ -786,7 +800,7 @@ async function installMcpClients( selected = await pickMcpClients(detected); if (selected.length === 0) { console.log('No agents selected. Nothing changed.'); - return; + return false; } } } @@ -815,6 +829,7 @@ async function installMcpClients( } reportMcpResults(results, ctx, options, Boolean(apiKey)); + return results.every((result) => result.mcpStatus !== 'failed'); } function ruleLine( From 57b8f17bd83a78a2a619d6203d97ece76ef04781 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Fri, 14 Aug 2026 12:53:35 -0700 Subject: [PATCH 31/35] fix(cli): say when requested rules cannot be installed Hermes has no global rule file, so --rules was going silent: clients only reach unsupported after rules were requested, and the dropped line hid that. --- src/__tests__/commands/setup.test.ts | 24 ++++++++++++++++++++++++ src/__tests__/utils/mcp-install.test.ts | 8 ++++++++ src/commands/setup.ts | 11 ++++++++--- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 5558343319..e5279bb77d 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -429,6 +429,30 @@ describe('handleSetupCommand', () => { ).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', { + clients: ['hermes'], + yes: true, + rules: true, + }); + + 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 }); diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 1a1bbc2bec..5445801b73 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -579,6 +579,14 @@ describe('mcp install', () => { 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('writes no rule for an agent whose MCP entry failed', async () => { const file = path.join(ctx.home, '.cursor', 'mcp.json'); mkdirSync(path.dirname(file), { recursive: true }); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 68d25ab469..298aa29ddb 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -843,11 +843,16 @@ function ruleLine( case 'skipped': return ' Rules skipped'; case 'unsupported': - // Nothing was asked for and nothing can be done about it, so saying so - // every run is noise. `skipped` still prints: that one was asked for. - return undefined; + // 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. + return ` 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; + } } } From ff0d3f02e427b0ecca37aaa5b0382c456facb02c Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Fri, 14 Aug 2026 13:36:15 -0700 Subject: [PATCH 32/35] fix(cli): stop native MCP setup from writing unusable agent config Parse Codex TOML by AST so quoted and BOM-prefixed tables are updated in place, honor CODEX_HOME and HERMES_HOME, write VS Code rules to the documented instructions path, and reject empty install-mode runs. --- package.json | 1 + pnpm-lock.yaml | 17 ++ src/__tests__/commands/doctor.test.ts | 251 +++++++++++++++++++++++- src/__tests__/commands/launch.test.ts | 28 ++- src/__tests__/commands/setup.test.ts | 79 +++++++- src/__tests__/utils/mcp-install.test.ts | 158 ++++++++++++++- src/commands/launch.ts | 12 +- src/commands/setup.ts | 21 +- src/utils/agents.ts | 186 +++++++++++------- src/utils/mcp-clients.ts | 89 ++++++--- src/utils/mcp-install.ts | 228 +++++++++------------ 11 files changed, 809 insertions(+), 261 deletions(-) diff --git a/package.json b/package.json index ef0c8f1ed6..659de2a6c6 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "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 b056306794..0c2029e2af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: 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 @@ -630,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==} @@ -955,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==} @@ -1521,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 @@ -1835,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 506531e2f4..1c2145d9a9 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; @@ -86,6 +99,242 @@ describe('hasFirecrawlMcpEntry', () => { }) ).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('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('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 b62e76f121..4e8fe0e8a1 100644 --- a/src/__tests__/commands/launch.test.ts +++ b/src/__tests__/commands/launch.test.ts @@ -317,26 +317,24 @@ describe('handleLaunchCommand', () => { expect(installSkillsForAgent).not.toHaveBeenCalled(); }); - it('does not claim MCP was configured when install mode skipped it', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - try { - await handleLaunchCommand('opencode', { - install: true, - skipMcp: true, - skipSkills: true, - }); + 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(); - expect(log).not.toHaveBeenCalledWith( - expect.stringContaining('configured with Firecrawl MCP') - ); - } finally { - log.mockRestore(); } - }); + ); 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 e5279bb77d..fe02035d15 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -67,6 +67,8 @@ describe('handleSetupCommand', () => { 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; @@ -100,8 +102,12 @@ describe('handleSetupCommand', () => { 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(() => { @@ -120,6 +126,10 @@ describe('handleSetupCommand', () => { } 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; @@ -568,6 +578,7 @@ describe('handleSetupCommand', () => { 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', { @@ -581,10 +592,12 @@ describe('handleSetupCommand', () => { 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(true); + ).toBe(false); } finally { + log.mockRestore(); Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: originalIsTTY, @@ -592,6 +605,70 @@ describe('handleSetupCommand', () => { } }); + 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('writes Codex config and rules under CODEX_HOME', async () => { + const override = path.join(sandboxHome, 'codex-override'); + process.env.CODEX_HOME = override; + + await handleSetupCommand('mcp', { + 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'); diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 5445801b73..431dc9c483 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -11,15 +11,19 @@ import { 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, @@ -312,7 +316,52 @@ describe('mcp install', () => { upsertTomlServer('instructions = """\nstill open\n', 'firecrawl', { url: MCP_URL, }) - ).toThrow('unterminated multi-line string'); + ).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/); }); }); @@ -565,12 +614,12 @@ describe('mcp install', () => { }); it('still configures MCP when the rule write fails', async () => { - // A file where the rules directory needs to be blocks the rule write. - const rulesPath = path.join(ctx.home, '.cursor', 'rules'); - mkdirSync(path.dirname(rulesPath), { recursive: true }); - writeFileSync(rulesPath, 'not a directory'); + // 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('cursor', { + const result = await setupMcpClient('vscode', { rules: true, ctx, }); @@ -587,21 +636,112 @@ describe('mcp install', () => { 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, '.cursor', 'mcp.json'); + 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('cursor', { rules: true, ctx }); + 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, '.cursor', 'rules', 'firecrawl.mdc')) + 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 }); diff --git a/src/commands/launch.ts b/src/commands/launch.ts index f17e5c277e..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 diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 298aa29ddb..c98d1a5103 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -45,8 +45,8 @@ import { type McpTargetId, } from '../utils/mcp-clients'; import { - appendRuleSection, setupMcpClient, + writeConfiguredRule, type McpClientResult, } from '../utils/mcp-install'; @@ -722,6 +722,13 @@ async function setupMcpLauncher( return result; } + if (rule.kind === 'manual') { + const written = await writeConfiguredRule(rule, ''); + result.ruleStatus = written.status; + result.ruleDetail = written.path; + return result; + } + 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 @@ -733,8 +740,9 @@ async function setupMcpLauncher( } try { - result.ruleStatus = await appendRuleSection(rulePath, rule.content); - result.ruleDetail = rulePath; + 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); @@ -845,8 +853,11 @@ function ruleLine( 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. - return ` Rules ${dim}not supported by this agent${reset}`; + // 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: { diff --git a/src/utils/agents.ts b/src/utils/agents.ts index 499c1ca455..cacf067561 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -3,13 +3,24 @@ * 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 { execFileSync } from 'child_process'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; +import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; +import { parseDocument } from 'yaml'; +import { + createMcpContext, + MCP_CLIENTS, + type McpClientId, + type McpContext, +} from './mcp-clients'; +import { tomlHasServer } from './mcp-install'; export type AgentId = | 'cursor' @@ -18,6 +29,9 @@ export type AgentId = | 'vscode' | 'windsurf' | 'codex' + | 'opencode' + | 'hermes' + | 'openclaw' | 'continue'; export interface AgentDetection { @@ -35,14 +49,24 @@ 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 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 +79,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 = execFileSync( + 'openclaw', + ['mcp', 'show', 'firecrawl', '--json'], + { + encoding: 'utf8', + timeout: 8000, + stdio: ['ignore', 'pipe', 'ignore'], + } + ); + const parsed: unknown = JSON.parse(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 +134,45 @@ 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')], + 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,29 +189,21 @@ 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']); } + const errors: ParseError[] = []; + const parsed = parseJsonc(content, errors, { allowTrailingComma: true }); + if (errors.length > 0) return false; return hasFirecrawlMcpEntry(parsed); } catch { return false; @@ -225,11 +269,15 @@ export async function detectAgents( 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 index 9f4fe42a6b..589916f39c 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -19,6 +19,7 @@ import { 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'; @@ -67,15 +68,52 @@ export interface McpContext { auth: McpAuthMode; } -export interface 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; +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'); } /** @@ -121,12 +159,6 @@ const RULE_BODY = `Use Firecrawl tools whenever a task needs content from the li /** Fences the rule inside files the user also writes to. */ export const RULE_MARKER = ''; -const CURSOR_RULE = `--- -alwaysApply: true ---- - -${RULE_BODY}`; - const VSCODE_RULE = `--- applyTo: '**' --- @@ -163,7 +195,7 @@ function appSupportDir(ctx: McpContext, name: string): string { } /** Claude Code relocates its whole config tree when CLAUDE_CONFIG_DIR is set. */ -function claudeConfigDir(ctx: McpContext): string { +export function claudeConfigDir(ctx: McpContext): string { const override = ctx.env.CLAUDE_CONFIG_DIR; return override && override !== '' ? override @@ -227,11 +259,11 @@ export const MCP_CLIENTS: Record = { 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: 'file', - content: CURSOR_RULE, - globalPath: (ctx) => - path.join(ctx.home, '.cursor', 'rules', 'firecrawl.mdc'), + 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')], @@ -252,7 +284,12 @@ export const MCP_CLIENTS: Record = { kind: 'file', content: VSCODE_RULE, globalPath: (ctx) => - path.join(vscodeUserDir(ctx), 'prompts', 'firecrawl.instructions.md'), + 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. @@ -268,7 +305,7 @@ export const MCP_CLIENTS: Record = { name: 'Codex', format: 'toml', serversKey: 'mcp_servers', - globalConfigPath: (ctx) => path.join(ctx.home, '.codex', 'config.toml'), + 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) => @@ -278,7 +315,7 @@ export const MCP_CLIENTS: Record = { rule: { kind: 'append', content: RULE_BODY, - globalPath: (ctx) => path.join(ctx.home, '.codex', 'AGENTS.md'), + 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 @@ -286,7 +323,7 @@ export const MCP_CLIENTS: Record = { oauth: { nextStep: 'codex mcp login firecrawl, or Authenticate in Codex settings', }, - detectPaths: (ctx) => [path.join(ctx.home, '.codex')], + detectPaths: (ctx) => [codexHome(ctx)], }, opencode: { id: 'opencode', @@ -315,7 +352,7 @@ export const MCP_CLIENTS: Record = { name: 'Hermes Agent', format: 'yaml', serversKey: 'mcp_servers', - globalConfigPath: (ctx) => path.join(ctx.home, '.hermes', 'config.yaml'), + 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, @@ -332,7 +369,7 @@ export const MCP_CLIENTS: Record = { entry: { auth: 'oauth' }, nextStep: 'hermes mcp login firecrawl, from a new terminal', }, - detectPaths: (ctx) => [path.join(ctx.home, '.hermes')], + detectPaths: (ctx) => [hermesHome(ctx)], }, }; diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 6959ae2c77..38f51ac59a 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -5,13 +5,14 @@ * 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 line by line, and shared rule - * files get a marker-fenced section rather than a rewrite. + * 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, @@ -21,6 +22,7 @@ import { type McpClient, type McpClientId, type McpContext, + type McpRuleSpec, type McpTargetId, } from './mcp-clients'; @@ -182,82 +184,55 @@ export function upsertYamlServer( }; } -/** - * True when the character at `index` is escaped. Backslashes escape each other, - * so only an odd run of them before the position leaves it escaped. - */ -function isEscaped(line: string, index: number): boolean { - let backslashes = 0; - for (let at = index - 1; at >= 0 && line[at] === '\\'; at -= 1) { - backslashes += 1; - } - return backslashes % 2 === 1; +function stripBom(content: string): { bom: string; raw: string } { + return content.startsWith('\uFEFF') + ? { bom: '\uFEFF', raw: content.slice(1) } + : { bom: '', raw: content }; } -/** Advance past a single-line basic or literal string, escapes included. */ -function skipQuoted(line: string, start: number, quote: string): number { - let index = start + 1; - while (index < line.length) { - // Only basic strings honour backslash escapes; literal strings have none. - if (quote === '"' && line[index] === '\\') { - index += 2; - continue; - } - if (line[index] === quote) return index + 1; - index += 1; +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); } - return line.length; } -/** - * Mark the lines that begin outside a multi-line string, so a `[table]` written - * inside one is not mistaken for a real table header. Throws when a multi-line - * string is still open at the end, which is malformed TOML: editing a file this - * scan cannot follow would corrupt it while reporting success. - */ -function linesOutsideStrings(lines: string[]): boolean[] { - const outside: boolean[] = []; - let fence: '"""' | "'''" | null = null; - - for (const line of lines) { - outside.push(fence === null); - let index = 0; - while (index < line.length) { - if (fence) { - let close = line.indexOf(fence, index); - // A basic string honours escapes, so `\"""` is an escaped quote - // followed by two literal ones rather than the terminator. Literal - // strings have no escapes, so their fence always closes. - while (close !== -1 && fence === '"""' && isEscaped(line, close)) { - close = line.indexOf(fence, close + 1); - } - if (close === -1) break; - index = close + fence.length; - fence = null; - continue; - } - if (line[index] === '#') break; - if (line.startsWith('"""', index) || line.startsWith("'''", index)) { - fence = line[index] === '"' ? '"""' : "'''"; - index += 3; - continue; - } - if (line[index] === '"' || line[index] === "'") { - index = skipQuoted(line, index, line[index]); - continue; - } - index += 1; - } - } +function isFirecrawlTable(node: AST.TOMLTable, serverName: string): boolean { + return ( + node.resolvedKey[0] === 'mcp_servers' && + String(node.resolvedKey[1]) === serverName + ); +} - if (fence) throw new Error('unterminated multi-line string'); - return outside; +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. Any 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. + * 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. */ @@ -266,74 +241,44 @@ export function upsertTomlServer( 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)}` ), - ]; - - // Preserve the file's existing line ending; a CRLF config must not be - // treated as one unmatchable line per table. - const eol = content.includes('\r\n') ? '\r\n' : '\n'; - const lines = content === '' ? [] : content.split(/\r?\n/); - const escaped = escapeRegExp(serverName); - const ownTable = new RegExp( - `^[ \\t]*\\[mcp_servers\\.${escaped}(\\.[^\\]]+)?\\][ \\t]*(?:#.*)?$` - ); - const anyTable = /^[ \t]*\[/; - const outside = linesOutsideStrings(lines); + ].join(eol); - const start = lines.findIndex( - (line, index) => outside[index] && ownTable.test(line) - ); + const finish = (next: string, alreadyExists: boolean) => { + parseTomlDocument(next); + return { content: `${bom}${next}`, alreadyExists }; + }; - if (start === -1) { - // Tables must follow root-level keys, so append at the end of the file. - const trimmed = [...lines]; - while (trimmed.length > 0 && trimmed[trimmed.length - 1].trim() === '') { - trimmed.pop(); - } - const separator = trimmed.length === 0 ? [] : ['']; - return { - content: [...trimmed, ...separator, ...block, ''].join(eol), - alreadyExists: false, - }; + if (raw.trim() === '') { + return finish(`${block}${eol}`, false); } - let end = start + 1; - while (end < lines.length) { - if ( - outside[end] && - anyTable.test(lines[end]) && - !ownTable.test(lines[end]) - ) { - break; + 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` + ); } - end += 1; - } - // Comments and blank lines directly above the next table introduce it, so - // they belong to the user's content rather than to the block being replaced. - while (end - 1 > start && /^[ \t]*(#.*)?$/.test(lines[end - 1])) { - end -= 1; + const trimmed = raw.replace(/(?:\r?\n)+$/, ''); + return finish(`${trimmed}${eol}${eol}${block}${eol}`, false); } - const rest = lines.slice(end); - // Keep a blank line between our block and whatever follows it. - const separator = rest.length > 0 && rest[0].trim() !== '' ? [''] : []; - const replaced = [ - ...lines.slice(0, start), - ...block, - ...separator, - ...rest, - ].join(eol); - - // Consuming the old table can swallow the file's final newline; restoring it - // keeps repeat runs byte-identical. - return { - content: replaced.endsWith(eol) ? replaced : `${replaced}${eol}`, - alreadyExists: true, - }; + const start = Math.min(...tables.map((table) => table.range[0])); + const end = Math.max(...tables.map((table) => table.range[1])); + const replaced = `${raw.slice(0, start)}${block}${raw.slice(end)}`; + return finish(replaced.endsWith('\n') ? replaced : `${replaced}${eol}`, true); } /** Rewrite a rule file we own outright. */ @@ -447,19 +392,38 @@ async function writeMcpEntry( 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.globalPath(ctx); - const status = - rule.kind === 'file' - ? await writeRuleFile(rulePath, rule.content) - : await appendRuleSection(rulePath, rule.content); - return { status, path: rulePath }; + const rulePath = rule.kind === 'manual' ? '' : rule.globalPath(ctx); + return writeConfiguredRule(rule, rulePath); } /** From 89a63ba4eeeea23800690e691bf1efaae1aefab6 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Fri, 14 Aug 2026 13:44:00 -0700 Subject: [PATCH 33/35] fix(cli): keep setup --yes and doctor honest when MCP is optional Do not fail the default setup bundle after skills when no agents are installed, and treat a recovered JSONC tree as registered when it still contains Firecrawl. --- src/__tests__/commands/doctor.test.ts | 30 +++++++++++++++++++++++++++ src/__tests__/commands/setup.test.ts | 30 +++++++++++++++++++++++++++ src/commands/setup.ts | 12 ++++++++--- src/utils/agents.ts | 11 +++++----- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/__tests__/commands/doctor.test.ts b/src/__tests__/commands/doctor.test.ts index 1c2145d9a9..4c4245ac68 100644 --- a/src/__tests__/commands/doctor.test.ts +++ b/src/__tests__/commands/doctor.test.ts @@ -195,6 +195,36 @@ describe('detectAgents', () => { 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 }); diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index fe02035d15..de427d938b 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -226,6 +226,36 @@ describe('handleSetupCommand', () => { ).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', { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index c98d1a5103..ee693e6049 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -798,9 +798,15 @@ async function installMcpClients( ...detectMcpLaunchers(ctx), ]; if (detected.length === 0 && !includeAllLaunchers) { - throw new Error( - 'No coding agents detected. Pass an agent flag such as --claude or --cursor.' - ); + 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; diff --git a/src/utils/agents.ts b/src/utils/agents.ts index cacf067561..5d672505ab 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -12,7 +12,7 @@ import { execFileSync } from 'child_process'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; -import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; +import { parse as parseJsonc } from 'jsonc-parser'; import { parseDocument } from 'yaml'; import { createMcpContext, @@ -201,10 +201,11 @@ async function fileHasFirecrawlMcp(filePath: string): Promise { return doc.errors.length === 0 && doc.hasIn(['mcp_servers', 'firecrawl']); } - const errors: ParseError[] = []; - const parsed = parseJsonc(content, errors, { allowTrailingComma: true }); - if (errors.length > 0) return false; - 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; } From d9272da1fa5dd5d9eac136e15dc5fca5685d8b38 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Fri, 14 Aug 2026 16:47:14 -0700 Subject: [PATCH 34/35] fix(cli): edit only Firecrawl's own tables when rewriting Codex config upsertTomlServer replaced everything between the first Firecrawl table and the last, so an unrelated [mcp_servers.other] sitting between the entry and its env sub-table was deleted along with them. Each table is now removed on its own, in reverse order so the earlier offsets stay valid in the mutated string, and the replacement goes in where the first one started. The first table keeps its terminating newline. The replacement block is written without one, so a config whose next table follows immediately, which is the documented stdio layout of [mcp_servers.firecrawl] above [mcp_servers.firecrawl.env], came back as `url = "..."[mcp_servers.other]` and failed to reparse. The user saw "could not parse existing config" about a file that was valid TOML. runClientCommand moves to its own module. Doctor's OpenClaw probe called execFileSync directly, which cannot launch a .cmd shim, so on Windows it reported the server unregistered for the exact reason the wrapper exists. OpenClaw also counts as installed when the launcher is on PATH rather than only when ~/.openclaw exists, which is what setup already detects. --- src/__tests__/commands/doctor.test.ts | 30 +++++++ src/__tests__/utils/mcp-install.test.ts | 86 ++++++++++++++++++++ src/commands/setup.ts | 102 +----------------------- src/utils/agents.ts | 17 ++-- src/utils/mcp-install.ts | 26 +++++- src/utils/run-client-command.ts | 94 ++++++++++++++++++++++ 6 files changed, 247 insertions(+), 108 deletions(-) create mode 100644 src/utils/run-client-command.ts diff --git a/src/__tests__/commands/doctor.test.ts b/src/__tests__/commands/doctor.test.ts index 4c4245ac68..8db6b3e732 100644 --- a/src/__tests__/commands/doctor.test.ts +++ b/src/__tests__/commands/doctor.test.ts @@ -239,6 +239,36 @@ describe('detectAgents', () => { 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( diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 431dc9c483..a6514b8ae1 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -166,6 +166,92 @@ describe('mcp install', () => { ); }); + 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"', diff --git a/src/commands/setup.ts b/src/commands/setup.ts index ee693e6049..486f15b18a 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -3,7 +3,7 @@ * Installs firecrawl skill files and MCP server into AI coding agents */ -import { execFileSync, execSync } from 'child_process'; +import { execSync } from 'child_process'; import { existsSync } from 'fs'; import os from 'os'; import path from 'path'; @@ -49,6 +49,7 @@ import { writeConfiguredRule, type McpClientResult, } from '../utils/mcp-install'; +import { runClientCommand } from '../utils/run-client-command'; export type SetupSubcommand = 'skills' | 'workflows' | 'mcp' | 'defaults'; @@ -98,105 +99,6 @@ 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] -): 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); -} - function firecrawlHostedMcpUrl(oauth = false): string { return oauth ? FIRECRAWL_MCP_OAUTH_URL : FIRECRAWL_MCP_URL; } diff --git a/src/utils/agents.ts b/src/utils/agents.ts index 5d672505ab..d6799a9b3f 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -8,7 +8,6 @@ * `openclaw mcp show firecrawl --json`. */ -import { execFileSync } from 'child_process'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; @@ -16,11 +15,13 @@ 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' @@ -51,6 +52,8 @@ interface AgentSpec { presencePaths: () => string[]; /** 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; } @@ -104,7 +107,7 @@ function fromClient( */ export function openclawFirecrawlRegistered(): boolean { try { - const stdout = execFileSync( + const stdout = runClientCommand( 'openclaw', ['mcp', 'show', 'firecrawl', '--json'], { @@ -113,7 +116,7 @@ export function openclawFirecrawlRegistered(): boolean { stdio: ['ignore', 'pipe', 'ignore'], } ); - const parsed: unknown = JSON.parse(stdout); + const parsed: unknown = JSON.parse(String(stdout)); if (!parsed || typeof parsed !== 'object') return false; return (parsed as { ok?: unknown }).ok !== false; } catch { @@ -164,6 +167,7 @@ const SPECS: AgentSpec[] = [ id: 'openclaw', name: 'OpenClaw', presencePaths: () => [path.join(homedir(), '.openclaw')], + isInstalled: () => detectMcpLaunchers(doctorContext()).includes('openclaw'), mcpConfigPaths: () => [], probeRegistered: openclawFirecrawlRegistered, }, @@ -264,8 +268,11 @@ 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; diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 38f51ac59a..560271677b 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -275,12 +275,32 @@ export function upsertTomlServer( return finish(`${trimmed}${eol}${eol}${block}${eol}`, false); } - const start = Math.min(...tables.map((table) => table.range[0])); - const end = Math.max(...tables.map((table) => table.range[1])); - const replaced = `${raw.slice(0, start)}${block}${raw.slice(end)}`; + 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, 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); +} From 79f2aa6e61710dc306b6061c405926cbc77f3d2e Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Fri, 14 Aug 2026 17:05:46 -0700 Subject: [PATCH 35/35] fix(cli): install MCP into the agents the user has, not all of them init passed `--agent all` whenever FIRECRAWL_API_KEY was exported. That was a workaround for a guard this branch removed, back when environment-backed setup refused to run without a named agent, and `all` now means every supported agent whether or not it is installed. On a machine with only Claude Code it wrote six config files, including ~/.cursor/mcp.json and VS Code's, then failed on OpenClaw and ended the run pointing at "firecrawl setup mcp". With no agent named, detection runs instead and resolves the exported key on its own, so the same machine gets one file and a clean finish. The rule fence now pairs the last two markers rather than the first two. A file carrying an odd marker, from a half-written run or a hand edit, paired that stray one with our opening marker, so the next run replaced the span between them and took the user's own text with it. Also: `openclaw config get` gets the 8s timeout doctor's probe already has, since its stderr is discarded and a wedged launcher would otherwise hang setup with nothing on screen; a launcher result records the mode the run configured instead of folding oauth in with keyless; and the `--agent` help no longer advertises the environment-backed requirement that is gone. --- src/__tests__/utils/mcp-install.test.ts | 16 ++++++++++++++++ src/commands/init.ts | 5 ++++- src/commands/setup.ts | 7 ++++++- src/index.ts | 2 +- src/utils/mcp-install.ts | 22 ++++++++++++---------- 5 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index a6514b8ae1..a51d658095 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -596,6 +596,22 @@ describe('mcp install', () => { 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', () => { diff --git a/src/commands/init.ts b/src/commands/init.ts index db5e9c3d60..1822f279e7 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -627,7 +627,10 @@ async function stepIntegrations( // 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. diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 486f15b18a..436de78cd0 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -560,6 +560,9 @@ function openclawConfiguredWorkspace( 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)); @@ -589,7 +592,9 @@ async function setupMcpLauncher( name: mcpTargetName(id), mcpStatus: 'failed', mcpDetail: '', - auth: keyless ? 'keyless' : 'env', + // 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: '', }; diff --git a/src/index.ts b/src/index.ts index aa8ff14fc4..988ae59069 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2244,7 +2244,7 @@ const setupCommand = program ) .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', diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 560271677b..7f66ab035c 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -70,10 +70,6 @@ async function writeFileEnsuringDir( await fs.writeFile(filePath, content, { encoding: 'utf8', mode: createMode }); } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - /** * Insert or replace `serversKey.serverName` without disturbing the rest of the * file. Throws when the existing file is not parseable, so a malformed config @@ -324,15 +320,21 @@ export async function appendRuleSection( // 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}`; - const marker = escapeRegExp(RULE_MARKER); - const fenced = new RegExp(`${marker}\\r?\\n[\\s\\S]*?${marker}`); - if (fenced.test(existing)) { - // Replace via a function so nothing in the rule body is read as a - // replacement pattern. + // 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.replace(fenced, () => section) + `${existing.slice(0, open)}${section}${existing.slice(close + RULE_MARKER.length)}` ); return 'updated'; }