From 7f476067dc0eafbe7f991554b555dea24bd9d01e Mon Sep 17 00:00:00 2001 From: erikengervall <5392475+erikengervall@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:59:44 +0000 Subject: [PATCH] feat(setup): configure Amp, Grok Build and Zed with setup mcp Squashed onto main 82229b1. The branch's merged-in work from #326, #336, #337, #338 and #339 is already on main; this commit holds only the Amp, Grok Build and Zed setup mcp change and the bump to 1.26.17. --- README.md | 13 +- package.json | 4 +- pnpm-lock.yaml | 17 ++ src/__tests__/commands/setup.test.ts | 314 +++++++++++++++++++++++++++ src/commands/setup.ts | 271 +++++++++++++++++++++++ 5 files changed, 615 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e2a468eb40..957188fc7d 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,19 @@ stored key into client configs. Use `--agent ` for one client, or `--agent all --global` to configure Claude Code, VS Code, Codex, OpenCode, Cursor, Gemini CLI, Windsurf, -Antigravity, Hermes Agent, and OpenClaw in one run. Clients that cannot be +Antigravity, Amp, Grok Build, Zed, Hermes Agent, and OpenClaw in one run. The +`--agent` values for the newer clients are `amp` (or `ampcode`, `amp-code`), +`grok` (or `grok-build`, `grok-cli`), and `zed`. Amp, Grok Build, and Zed are written to their user settings, so they do not take +`--project`: Amp to `~/.config/amp/settings.json` (or an existing +`settings.jsonc` when there is no `settings.json`), Grok Build to +`~/.grok/config.toml`, and Zed to `~/.config/zed/settings.json` +(`$XDG_CONFIG_HOME/zed/settings.json` on Linux when it is set, +`%APPDATA%\Zed\settings.json` on Windows). Clients that cannot be configured on this machine (for example OpenClaw when the `openclaw` command is not installed) are skipped and listed in the summary; the command only fails when a client it tried to configure fails. When `FIRECRAWL_API_KEY` is set, -Antigravity is skipped because it has no verified way to reference the key from -the environment; add `--keyless` to include it. +Antigravity and Zed are skipped because they have no verified way to reference +the key from the environment; add `--keyless` to include them. To make Firecrawl the default web provider for supported AI agents: diff --git a/package.json b/package.json index 5e4844e079..f5c1fdf948 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.26.16", + "version": "1.26.17", "publishConfig": { "tag": "latest" }, @@ -86,6 +86,8 @@ "@inquirer/prompts": "^8.2.1", "commander": "^14.0.2", "firecrawl": "4.50.0", + "jsonc-parser": "^3.3.1", + "smol-toml": "^1.9.1", "string-width": "^4.2.3", "yaml": "^2.9.0", "zod-to-json-schema": "3.24.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f19f30f8b..e99dcda164 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: firecrawl: specifier: 4.50.0 version: 4.50.0 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 + smol-toml: + specifier: ^1.9.1 + version: 1.9.1 string-width: specifier: ^4.2.3 version: 4.2.3 @@ -774,6 +780,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'} @@ -926,6 +935,10 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + smol-toml@1.9.1: + resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1683,6 +1696,8 @@ snapshots: isexe@2.0.0: {} + jsonc-parser@3.3.1: {} + lilconfig@3.1.3: {} lint-staged@15.5.2: @@ -1844,6 +1859,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smol-toml@1.9.1: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 31d74634be..d15e1bf1c1 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -11,6 +11,8 @@ import { } from 'fs'; import os from 'os'; import path from 'path'; +import { parse as parseJsonc } from 'jsonc-parser'; +import { parse as parseToml } from 'smol-toml'; import { handleMakeDefaultCommand, handleSetupCommand, @@ -980,6 +982,318 @@ describe('handleSetupCommand', () => { } }); + describe('config-file clients (Amp, Grok Build, Zed)', () => { + let home: string; + let originalXdg: string | undefined; + let originalAppData: string | undefined; + + beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-config-file-')); + process.env.HOME = home; + vi.spyOn(os, 'homedir').mockReturnValue(home); + originalXdg = process.env.XDG_CONFIG_HOME; + delete process.env.XDG_CONFIG_HOME; + originalAppData = process.env.APPDATA; + process.env.APPDATA = path.join(home, 'AppData', 'Roaming'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalXdg; + if (originalAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = originalAppData; + vi.mocked(os.homedir).mockRestore(); + rmSync(home, { recursive: true, force: true }); + }); + + const ampPath = () => path.join(home, '.config', 'amp', 'settings.json'); + const grokPath = () => path.join(home, '.grok', 'config.toml'); + const zedPath = () => + process.platform === 'win32' + ? path.join(home, 'AppData', 'Roaming', 'Zed', 'settings.json') + : path.join(home, '.config', 'zed', 'settings.json'); + const readJsonc = (filePath: string) => + parseJsonc(readFileSync(filePath, 'utf-8')) as Record; + + it('writes an environment-backed Amp entry and keeps existing settings and comments', async () => { + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + mkdirSync(path.dirname(ampPath()), { recursive: true }); + writeFileSync( + ampPath(), + '{\n // keep me\n "amp.notifications.enabled": false,\n "amp.mcpServers": {\n "linear": { "url": "https://mcp.linear.app/sse" }\n }\n}\n' + ); + + await handleSetupCommand('mcp', { agent: 'amp', yes: true }); + const first = readFileSync(ampPath(), 'utf-8'); + await handleSetupCommand('mcp', { agent: 'ampcode', yes: true }); + + const raw = readFileSync(ampPath(), 'utf-8'); + expect(raw).toBe(first); + expect(raw.match(/"firecrawl"/g)).toHaveLength(1); + expect(raw).toContain('// keep me'); + expect(raw).not.toContain('fc-test-key'); + const settings = readJsonc(ampPath()); + expect(settings['amp.notifications.enabled']).toBe(false); + expect(settings['amp.mcpServers']).toEqual({ + linear: { url: 'https://mcp.linear.app/sse' }, + firecrawl: { + url: 'https://mcp.firecrawl.dev/v2/mcp', + headers: { Authorization: 'Bearer ${FIRECRAWL_API_KEY}' }, + }, + }); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it('writes a keyless Amp entry to settings.jsonc when only that file exists', async () => { + const jsoncPath = `${ampPath()}c`; + mkdirSync(path.dirname(jsoncPath), { recursive: true }); + writeFileSync(jsoncPath, '// amp\n{}\n'); + + await installMcp({ agent: 'amp', keyless: true }); + + expect(existsSync(ampPath())).toBe(false); + expect(readJsonc(jsoncPath)['amp.mcpServers']).toEqual({ + firecrawl: { url: 'https://mcp.firecrawl.dev/v2/mcp' }, + }); + }); + + it('rejects a stored key before writing Amp settings', async () => { + await expect( + handleSetupCommand('mcp', { agent: 'amp', yes: true }) + ).rejects.toThrow('Export FIRECRAWL_API_KEY'); + expect(existsSync(ampPath())).toBe(false); + }); + + it('replaces the Grok Build firecrawl table and keeps the rest of config.toml', async () => { + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + mkdirSync(path.dirname(grokPath()), { recursive: true }); + const before = + '# my grok config\n[models]\ndefault = "grok-build" \n\n'; + const after = + '[mcp_servers.linear]\nurl = "https://mcp.linear.app/mcp"\n'; + writeFileSync( + grokPath(), + before + + '[mcp_servers.firecrawl]\nurl = "https://old.example.com/mcp"\n\n' + + '[mcp_servers.firecrawl.headers]\nAuthorization = "Bearer old"\n\n' + + after + ); + + await handleSetupCommand('mcp', { agent: 'grok', yes: true }); + const first = readFileSync(grokPath(), 'utf-8'); + await handleSetupCommand('mcp', { agent: 'grok-build', yes: true }); + + const raw = readFileSync(grokPath(), 'utf-8'); + expect(raw).toBe(first); + expect(raw.startsWith(before + after)).toBe(true); + expect(raw).not.toContain('old.example.com'); + expect(raw).not.toContain('fc-test-key'); + expect(raw.match(/\[mcp_servers\.firecrawl\]/g)).toHaveLength(1); + const config = parseToml(raw) as Record; + expect(config.models).toEqual({ default: 'grok-build' }); + expect(config.mcp_servers).toEqual({ + linear: { url: 'https://mcp.linear.app/mcp' }, + firecrawl: { + url: 'https://mcp.firecrawl.dev/v2/mcp', + headers: { Authorization: 'Bearer ${FIRECRAWL_API_KEY}' }, + }, + }); + }); + + it('keeps CRLF line endings in a Grok Build config.toml', async () => { + mkdirSync(path.dirname(grokPath()), { recursive: true }); + const original = '[models]\r\ndefault = "grok-build"\r\n'; + writeFileSync(grokPath(), original); + + await installMcp({ agent: 'grok-build', keyless: true }); + + const raw = readFileSync(grokPath(), 'utf-8'); + expect(raw.startsWith(original)).toBe(true); + expect(raw.replace(/\r\n/g, '')).not.toContain('\n'); + expect(parseToml(raw)).toEqual({ + models: { default: 'grok-build' }, + mcp_servers: { firecrawl: { url: 'https://mcp.firecrawl.dev/v2/mcp' } }, + }); + }); + + it('ignores a Grok Build table header inside a multiline string', async () => { + mkdirSync(path.dirname(grokPath()), { recursive: true }); + const original = [ + '[agent]', + 'notes = """', + '[mcp_servers.firecrawl]', + 'kept as text', + '"""', + '', + ].join('\n'); + writeFileSync(grokPath(), original); + + await installMcp({ agent: 'grok-build', keyless: true }); + + const raw = readFileSync(grokPath(), 'utf-8'); + expect(raw.startsWith(original)).toBe(true); + expect(parseToml(raw)).toEqual({ + agent: { notes: '[mcp_servers.firecrawl]\nkept as text\n' }, + mcp_servers: { firecrawl: { url: 'https://mcp.firecrawl.dev/v2/mcp' } }, + }); + }); + + it('writes a keyless Grok Build entry into a fresh home', async () => { + await installMcp({ agent: 'grok-build', keyless: true }); + + expect(parseToml(readFileSync(grokPath(), 'utf-8'))).toEqual({ + mcp_servers: { firecrawl: { url: 'https://mcp.firecrawl.dev/v2/mcp' } }, + }); + }); + + it('leaves an invalid Grok Build config.toml untouched', async () => { + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + mkdirSync(path.dirname(grokPath()), { recursive: true }); + writeFileSync(grokPath(), '[models\n'); + + await expect( + handleSetupCommand('mcp', { agent: 'grok', yes: true }) + ).rejects.toThrow('is not valid TOML'); + expect(readFileSync(grokPath(), 'utf-8')).toBe('[models\n'); + }); + + it('refuses a Grok Build firecrawl entry it cannot replace', async () => { + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + mkdirSync(path.dirname(grokPath()), { recursive: true }); + const original = + '[mcp_servers]\nfirecrawl = { url = "https://old.example.com/mcp" }\n'; + writeFileSync(grokPath(), original); + + await expect( + handleSetupCommand('mcp', { agent: 'grok', yes: true }) + ).rejects.toThrow('in a form setup cannot replace'); + expect(readFileSync(grokPath(), 'utf-8')).toBe(original); + }); + + it('writes a keyless Zed context server and keeps settings comments', async () => { + mkdirSync(path.dirname(zedPath()), { recursive: true }); + writeFileSync( + zedPath(), + '// Zed settings\n{\n "theme": "One Dark",\n "context_servers": {\n "github": { "url": "https://api.githubcopilot.com/mcp/" },\n },\n}\n' + ); + + await installMcp({ agent: 'zed', keyless: true }); + + const raw = readFileSync(zedPath(), 'utf-8'); + expect(raw).toContain('// Zed settings'); + const settings = readJsonc(zedPath()); + expect(settings.theme).toBe('One Dark'); + expect(settings.context_servers).toEqual({ + github: { url: 'https://api.githubcopilot.com/mcp/' }, + firecrawl: { url: 'https://mcp.firecrawl.dev/v2/mcp' }, + }); + }); + + it('adds the Zed entry to a settings file that holds only comments', async () => { + mkdirSync(path.dirname(zedPath()), { recursive: true }); + writeFileSync(zedPath(), '// Zed settings\n'); + + await installMcp({ agent: 'zed', keyless: true }); + + expect(readFileSync(zedPath(), 'utf-8')).toContain('// Zed settings'); + expect(readJsonc(zedPath()).context_servers).toEqual({ + firecrawl: { url: 'https://mcp.firecrawl.dev/v2/mcp' }, + }); + }); + + it('honors XDG_CONFIG_HOME for Zed on Linux', async () => { + if (process.platform !== 'linux') return; + const xdg = path.join(home, 'xdg'); + process.env.XDG_CONFIG_HOME = xdg; + + await installMcp({ agent: 'zed', keyless: true }); + + expect(existsSync(path.join(xdg, 'zed', 'settings.json'))).toBe(true); + expect(existsSync(zedPath())).toBe(false); + }); + + it('rejects an environment-backed key for Zed instead of writing it', async () => { + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + + await expect( + handleSetupCommand('mcp', { agent: 'zed', yes: true }) + ).rejects.toThrow('does not have a verified environment-variable syntax'); + expect(existsSync(zedPath())).toBe(false); + }); + + it('rejects --project for config-file clients', async () => { + await expect( + installMcp({ agent: 'amp', project: true, keyless: true }) + ).rejects.toThrow('Firecrawl configures Amp in its user settings only'); + expect(existsSync(path.join(process.cwd(), '.amp'))).toBe(false); + }); + + it('configures Amp and Grok Build and skips Zed with --agent all and an environment key', async () => { + const originalPath = process.env.PATH; + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + process.env.PATH = stubCommands(home, []); + + try { + await handleSetupCommand('mcp', { + agent: 'all', + global: true, + yes: true, + }); + + expect( + readJsonc(ampPath())['amp.mcpServers'].firecrawl.headers + ).toEqual({ Authorization: 'Bearer ${FIRECRAWL_API_KEY}' }); + expect(readFileSync(grokPath(), 'utf-8')).toContain( + 'Authorization = "Bearer ${FIRECRAWL_API_KEY}"' + ); + expect(existsSync(zedPath())).toBe(false); + const output = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(output).toMatch(/Configured: .*Amp, Grok Build/); + expect(output).toContain( + 'Skipping Zed: no verified environment-variable syntax' + ); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + } + }); + + it('includes Zed in keyless --agent all and skips all three for --project', async () => { + const originalPath = process.env.PATH; + process.env.PATH = stubCommands(home, []); + vi.mocked(getApiKey).mockReturnValue(undefined); + + try { + await handleSetupCommand('mcp', { + agent: 'all', + global: true, + yes: true, + }); + expect(readJsonc(zedPath()).context_servers.firecrawl).toEqual({ + url: 'https://mcp.firecrawl.dev/v2/mcp', + }); + + rmSync(path.join(home, '.config'), { recursive: true, force: true }); + rmSync(path.join(home, '.grok'), { recursive: true, force: true }); + await handleSetupCommand('mcp', { + agent: 'all', + project: true, + yes: true, + }); + expect(existsSync(ampPath())).toBe(false); + expect(existsSync(grokPath())).toBe(false); + expect(existsSync(zedPath())).toBe(false); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'Skipped: Amp (configured in user settings only, rerun without --project); Grok Build' + ); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + } + }); + }); + it('requires a client selection for no-agent environment-backed setup', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 72d8713ed6..2badcec389 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -16,6 +16,14 @@ import { } from 'fs'; import os from 'os'; import path from 'path'; +import { + applyEdits, + modify, + parse as parseJsonc, + stripComments, + type ParseError, +} from 'jsonc-parser'; +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { getApiKey, updateConfig } from '../utils/config'; import { browserLogin, isAuthenticated } from '../utils/auth'; @@ -56,6 +64,7 @@ type SetupIntegration = SetupSubcommand; type ResolvedMcpAgent = | { kind: 'add-mcp'; agent?: string; all?: boolean } + | { kind: 'config-file'; client: ConfigFileClient } | { kind: 'hermes' } | { kind: 'openclaw' } | { kind: 'all-launchers' }; @@ -96,6 +105,14 @@ const ADD_MCP_LAUNCH_AGENTS: Record = { antigravity: 'Antigravity', }; +/** Clients whose MCP entry this command writes straight into their user settings. */ +const CONFIG_FILE_CLIENTS = { + amp: 'Amp', + 'grok-build': 'Grok Build', + zed: 'Zed', +} as const; +type ConfigFileClient = keyof typeof CONFIG_FILE_CLIENTS; + const SKILL_REPO_LABELS: Record = { 'firecrawl/cli': 'Core CLI skills', 'firecrawl/skills': 'Build skills', @@ -247,8 +264,10 @@ function assertSubprocessSafeCredential( function environmentHeaderForAgent(agent?: string): string | undefined { switch (agent) { + case 'amp': case 'claude-code': case 'gemini-cli': + case 'grok-build': case 'hermes': case 'openclaw': return `Bearer \${${ENV_API_KEY}}`; @@ -315,6 +334,16 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { return { kind: 'add-mcp', agent: 'windsurf' }; case 'antigravity': return { kind: 'add-mcp', agent: 'antigravity' }; + case 'amp': + case 'ampcode': + case 'amp-code': + return { kind: 'config-file', client: 'amp' }; + case 'grok': + case 'grok-build': + case 'grok-cli': + return { kind: 'config-file', client: 'grok-build' }; + case 'zed': + return { kind: 'config-file', client: 'zed' }; case 'hermes': case 'hermes-agent': return { kind: 'hermes' }; @@ -731,6 +760,19 @@ export async function installMcp( await installAllMcpLaunchers(options, runtimeEnv); return; } + if (resolvedAgent.kind === 'config-file') { + if (options.project) { + throw new Error( + `Firecrawl configures ${CONFIG_FILE_CLIENTS[resolvedAgent.client]} in its user settings only; rerun without --project.` + ); + } + await installConfigFileMcp( + resolvedAgent.client, + runtimeEnv, + options.keyless + ); + return; + } await installAddMcp(options, resolvedAgent, runtimeEnv); } @@ -748,6 +790,17 @@ function addMcpSkipReason( return 'no verified environment-variable syntax for the API key, rerun with --keyless or configure it manually'; } +function configFileSkipReason( + client: ConfigFileClient, + apiKey: string | undefined, + project: boolean | undefined +): string | undefined { + if (project) + return 'configured in user settings only, rerun without --project'; + if (!apiKey || environmentHeaderForAgent(client)) return undefined; + return 'no verified environment-variable syntax for the API key, rerun with --keyless or configure it manually'; +} + /** * Configure every launch integration. A client that cannot be configured here * is skipped and reported; only a failure in a client we attempted is an error. @@ -768,6 +821,14 @@ async function installAllMcpLaunchers( runtimeEnv ), })), + ...(Object.keys(CONFIG_FILE_CLIENTS) as ConfigFileClient[]).map( + (client) => ({ + label: CONFIG_FILE_CLIENTS[client], + skipReason: configFileSkipReason(client, apiKey, options.project), + install: () => + installConfigFileMcp(client, runtimeEnv, options.keyless), + }) + ), { label: 'Hermes Agent', skipReason: undefined, @@ -942,6 +1003,216 @@ function firecrawlMcpConfig( }; } +/** User settings file each config-file client reads its MCP servers from. */ +function configFileMcpPath(client: ConfigFileClient): string { + const home = os.homedir(); + switch (client) { + case 'amp': { + const settings = path.join(home, '.config', 'amp', 'settings.json'); + return !existsSync(settings) && existsSync(`${settings}c`) + ? `${settings}c` + : settings; + } + case 'grok-build': + return path.join(home, '.grok', 'config.toml'); + case 'zed': { + if (process.platform === 'win32') { + const appData = + process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'); + return path.join(appData, 'Zed', 'settings.json'); + } + const configHome = + process.platform === 'linux' && process.env.XDG_CONFIG_HOME + ? process.env.XDG_CONFIG_HOME + : path.join(home, '.config'); + return path.join(configHome, 'zed', 'settings.json'); + } + } +} + +/** Set one server in a JSON or JSONC settings file, keeping comments and formatting. */ +function upsertJsoncServer( + content: string, + keyPath: string[], + entry: Record, + filePath: string +): string { + if (stripComments(content).trim() !== '') { + const errors: ParseError[] = []; + const root = parseJsonc(content, errors, { allowTrailingComma: true }); + if ( + errors.length > 0 || + typeof root !== 'object' || + root === null || + Array.isArray(root) + ) { + throw new Error( + `${filePath} is not valid JSON. Fix it, then rerun setup.` + ); + } + } + try { + const next = applyEdits( + content, + modify(content, keyPath, entry, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }) + ); + return content ? next : `${next}\n`; + } catch { + throw new Error( + `${filePath} has a ${keyPath[0]} value setup cannot update. Fix it, then rerun setup.` + ); + } +} + +/** Replace the [mcp_servers.firecrawl] table in a Grok config.toml, keeping everything else. */ +function upsertGrokServer( + content: string, + entry: Record, + filePath: string +): string { + const parseConfig = (text: string): Record => { + try { + return parseToml(text); + } catch { + throw new Error( + `${filePath} is not valid TOML. Fix it, then rerun setup.` + ); + } + }; + parseConfig(content); + + const firecrawlTable = + /^\s*\[\s*mcp_servers\s*\.\s*(?:firecrawl|"firecrawl"|'firecrawl')\s*(?:\.[^\]]*)?\]\s*(?:#.*)?$/; + const topLevel = tomlTopLevelLineStarts(content); + let kept = ''; + let skipping = false; + let offset = 0; + for (const line of content.split(/(?<=\n)/)) { + if (topLevel.has(offset) && /^\s*\[/.test(line)) { + skipping = firecrawlTable.test(line.replace(/\r?\n$/, '')); + } + if (!skipping) kept += line; + offset += line.length; + } + + const servers = parseConfig(kept).mcp_servers; + if ( + typeof servers === 'object' && + servers !== null && + 'firecrawl' in servers + ) { + throw new Error( + `${filePath} defines mcp_servers.firecrawl in a form setup cannot replace. Remove it, then rerun setup.` + ); + } + + const eol = content.includes('\r\n') ? '\r\n' : '\n'; + const block = stringifyToml({ mcp_servers: { firecrawl: entry } }) + .trimEnd() + .split('\n') + .join(eol); + let prefix = kept; + if (prefix.trim()) { + if (!prefix.endsWith('\n')) prefix += eol; + if (!/\n\s*\n$/.test(prefix)) prefix += eol; + } + const next = `${prefix}${block}${eol}`; + parseConfig(next); + return next; +} + +/** Offsets of the lines in a TOML document that start outside a string or comment. */ +function tomlTopLevelLineStarts(content: string): Set { + const starts = new Set(); + let state: + | 'none' + | 'basic' + | 'literal' + | 'ml-basic' + | 'ml-literal' + | 'comment' = 'none'; + for (let i = 0; i < content.length; i++) { + if ((i === 0 || content[i - 1] === '\n') && state === 'none') starts.add(i); + const char = content[i]; + if (char === '\n' && state !== 'ml-basic' && state !== 'ml-literal') { + state = 'none'; + continue; + } + switch (state) { + case 'comment': + break; + case 'basic': + if (char === '\\') i++; + else if (char === '"') state = 'none'; + break; + case 'literal': + if (char === "'") state = 'none'; + break; + case 'ml-basic': + if (char === '\\') i++; + else if (content.startsWith('"""', i)) { + i += 2; + while (content[i + 1] === '"') i++; + state = 'none'; + } + break; + case 'ml-literal': + if (content.startsWith("'''", i)) { + i += 2; + while (content[i + 1] === "'") i++; + state = 'none'; + } + break; + case 'none': + if (char === '#') state = 'comment'; + else if (content.startsWith('"""', i)) { + state = 'ml-basic'; + i += 2; + } else if (content.startsWith("'''", i)) { + state = 'ml-literal'; + i += 2; + } else if (char === '"') state = 'basic'; + else if (char === "'") state = 'literal'; + break; + } + } + return starts; +} + +/** Write the hosted Firecrawl MCP entry into Amp, Grok Build or Zed user settings. */ +async function installConfigFileMcp( + client: ConfigFileClient, + runtimeEnv: NodeJS.ProcessEnv = process.env, + keyless = false +): Promise { + const { url, headers } = firecrawlMcpConfig(client, runtimeEnv, keyless); + const entry = headers ? { url, headers } : { url }; + const configPath = configFileMcpPath(client); + const existing = existsSync(configPath) + ? readFileSync(configPath, 'utf-8') + : ''; + const next = + client === 'grok-build' + ? upsertGrokServer(existing, entry, configPath) + : upsertJsoncServer( + existing, + [ + client === 'amp' ? 'amp.mcpServers' : 'context_servers', + 'firecrawl', + ], + entry, + configPath + ); + + mkdirSync(path.dirname(configPath), { recursive: true }); + writeFileSync(configPath, next, 'utf-8'); + console.log( + `${CONFIG_FILE_CLIENTS[client]} MCP configured at ${configPath}.` + ); +} + export async function installHermesMcp( runtimeEnv: NodeJS.ProcessEnv = process.env, keyless = false