From 3c45bcd4248e2d68aadc81bd3b8205f655823c74 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:13:21 -0400 Subject: [PATCH 1/5] add startup update notice --- src/__tests__/utils/update-notice.test.ts | 123 ++++++++++++++++++++ src/index.ts | 3 + src/utils/update-notice.ts | 135 ++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 src/__tests__/utils/update-notice.test.ts create mode 100644 src/utils/update-notice.ts diff --git a/src/__tests__/utils/update-notice.test.ts b/src/__tests__/utils/update-notice.test.ts new file mode 100644 index 0000000000..30bd37e4b1 --- /dev/null +++ b/src/__tests__/utils/update-notice.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import packageJson from '../../../package.json'; +import { + maybeShowUpdateNotice, + formatUpdateNotice, + type UpdateNoticeOptions, +} from '../../utils/update-notice'; +import { getLatestVersion } from '../../utils/npm-registry'; + +vi.mock('../../utils/npm-registry', async () => { + const actual = await vi.importActual< + typeof import('../../utils/npm-registry') + >('../../utils/npm-registry'); + return { + ...actual, + getLatestVersion: vi.fn(), + }; +}); + +describe('update notice', () => { + let tmpDir: string; + let originalNoUpdateCheck: string | undefined; + let write: ReturnType; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'firecrawl-update-test-')); + originalNoUpdateCheck = process.env.FIRECRAWL_NO_UPDATE_CHECK; + delete process.env.FIRECRAWL_NO_UPDATE_CHECK; + vi.mocked(getLatestVersion).mockReset(); + write = vi.fn(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (originalNoUpdateCheck === undefined) { + delete process.env.FIRECRAWL_NO_UPDATE_CHECK; + } else { + process.env.FIRECRAWL_NO_UPDATE_CHECK = originalNoUpdateCheck; + } + vi.restoreAllMocks(); + }); + + function stderr(isTTY = true): UpdateNoticeOptions['stderr'] { + return { isTTY, write: write as unknown as NodeJS.WriteStream['write'] }; + } + + it('formats a bordered update notice', () => { + const notice = formatUpdateNotice('99.99.99'); + + expect(notice).toContain( + `✨ Update available! ${packageJson.version} -> 99.99.99` + ); + expect(notice).toContain('Run npm install -g firecrawl-cli to update.'); + expect(notice).toContain( + 'https://github.com/firecrawl/cli/releases/latest' + ); + expect(notice.startsWith('╭')).toBe(true); + expect(notice.endsWith('╯')).toBe(true); + }); + + it('does not check or print outside a TTY', async () => { + await maybeShowUpdateNotice({ cacheDir: tmpDir, stderr: stderr(false) }); + + expect(getLatestVersion).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); + + it('prints a cached newer version', async () => { + fs.writeFileSync( + path.join(tmpDir, 'update-check.json'), + JSON.stringify({ + latestVersion: '99.99.99', + checkedAt: new Date('2026-06-04T12:00:00.000Z').toISOString(), + }) + ); + + await maybeShowUpdateNotice({ + cacheDir: tmpDir, + now: new Date('2026-06-04T13:00:00.000Z'), + stderr: stderr(), + }); + + expect(getLatestVersion).not.toHaveBeenCalled(); + expect(write).toHaveBeenCalledWith( + expect.stringContaining( + `Update available! ${packageJson.version} -> 99.99.99` + ) + ); + }); + + it('refreshes a stale cache from npm', async () => { + vi.mocked(getLatestVersion).mockResolvedValue({ + version: '99.99.99', + unreachable: false, + }); + + await maybeShowUpdateNotice({ + cacheDir: tmpDir, + now: new Date('2026-06-04T13:00:00.000Z'), + stderr: stderr(), + }); + + expect(getLatestVersion).toHaveBeenCalledWith('firecrawl-cli', 750); + expect(write).toHaveBeenCalledWith(expect.stringContaining('99.99.99')); + + const cached = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'update-check.json'), 'utf-8') + ); + expect(cached.latestVersion).toBe('99.99.99'); + }); + + it('respects FIRECRAWL_NO_UPDATE_CHECK', async () => { + process.env.FIRECRAWL_NO_UPDATE_CHECK = '1'; + + await maybeShowUpdateNotice({ cacheDir: tmpDir, stderr: stderr() }); + + expect(getLatestVersion).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 2b63ddf317..2c504202e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,7 @@ import { isUrl, normalizeUrl } from './utils/url'; import { parseScrapeOptions } from './utils/options'; import { isJobId } from './utils/job'; import { ensureAuthenticated, printBanner } from './utils/auth'; +import { maybeShowUpdateNotice } from './utils/update-notice'; import packageJson from '../package.json'; import type { SearchSource, SearchCategory } from './types/search'; import type { ScrapeFormat } from './types/scrape'; @@ -1930,6 +1931,8 @@ async function main() { return; } + await maybeShowUpdateNotice(); + // If no arguments or just help flags, check auth and show appropriate message if (args.length === 0) { const { isAuthenticated } = await import('./utils/auth'); diff --git a/src/utils/update-notice.ts b/src/utils/update-notice.ts new file mode 100644 index 0000000000..21ced22c59 --- /dev/null +++ b/src/utils/update-notice.ts @@ -0,0 +1,135 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import packageJson from '../../package.json'; +import { compareVersions, getLatestVersion } from './npm-registry'; + +const CACHE_FILENAME = 'update-check.json'; +const CACHE_TTL_MS = 20 * 60 * 60 * 1000; +const RELEASE_NOTES_URL = 'https://github.com/firecrawl/cli/releases/latest'; +const INSTALL_COMMAND = 'npm install -g firecrawl-cli'; + +interface UpdateCache { + latestVersion: string; + checkedAt: string; +} + +export interface UpdateNoticeOptions { + cacheDir?: string; + now?: Date; + stderr?: Pick; +} + +function getCachePath(cacheDir?: string): string { + return path.join( + cacheDir ?? path.join(os.homedir(), '.firecrawl'), + CACHE_FILENAME + ); +} + +function updateCheckDisabled(): boolean { + const value = process.env.FIRECRAWL_NO_UPDATE_CHECK; + return value === '1' || value === 'true'; +} + +function supportsColor(stderr: Pick): boolean { + if (process.env.NO_COLOR !== undefined) return false; + if (process.env.FORCE_COLOR !== undefined) + return process.env.FORCE_COLOR !== '0'; + return Boolean(stderr.isTTY); +} + +async function readCachedLatest( + cachePath: string, + now: Date +): Promise<{ latestVersion?: string; stale: boolean }> { + try { + const raw = await fs.readFile(cachePath, 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.latestVersion !== 'string' || + typeof parsed.checkedAt !== 'string' + ) { + return { stale: true }; + } + + const checkedAt = new Date(parsed.checkedAt).getTime(); + const stale = + !Number.isFinite(checkedAt) || now.getTime() - checkedAt > CACHE_TTL_MS; + return { latestVersion: parsed.latestVersion, stale }; + } catch { + return { stale: true }; + } +} + +async function writeCachedLatest( + cachePath: string, + latestVersion: string, + now: Date +): Promise { + try { + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile( + cachePath, + `${JSON.stringify({ latestVersion, checkedAt: now.toISOString() })}\n`, + 'utf-8' + ); + } catch { + // Update checks should never make a real command fail. + } +} + +function colorize(message: string, stderr: Pick) { + if (!supportsColor(stderr)) return message; + return message + .replace('Update available!', '\x1b[1;36mUpdate available!\x1b[0m') + .replace(INSTALL_COMMAND, `\x1b[36m${INSTALL_COMMAND}\x1b[0m`) + .replace(RELEASE_NOTES_URL, `\x1b[36;4m${RELEASE_NOTES_URL}\x1b[0m`); +} + +export function formatUpdateNotice(latestVersion: string): string { + const currentVersion = packageJson.version; + const lines = [ + `✨ Update available! ${currentVersion} -> ${latestVersion}`, + `Run ${INSTALL_COMMAND} to update.`, + '', + 'See full release notes:', + RELEASE_NOTES_URL, + ]; + const width = Math.max(...lines.map((line) => line.length)); + const top = `╭${'─'.repeat(width + 2)}╮`; + const bottom = `╰${'─'.repeat(width + 2)}╯`; + const body = lines.map((line) => `│ ${line.padEnd(width)} │`); + return [top, ...body, bottom].join('\n'); +} + +function shouldShowNotice(latestVersion: string | undefined): boolean { + return ( + typeof latestVersion === 'string' && + compareVersions(packageJson.version, latestVersion) < 0 + ); +} + +export async function maybeShowUpdateNotice( + options: UpdateNoticeOptions = {} +): Promise { + const stderr = options.stderr ?? process.stderr; + if (!stderr.isTTY || updateCheckDisabled()) return; + + const now = options.now ?? new Date(); + const cachePath = getCachePath(options.cacheDir); + const cached = await readCachedLatest(cachePath, now); + + let latestVersion = cached.latestVersion; + if (cached.stale) { + const latest = await getLatestVersion(packageJson.name, 750); + if (!latest.unreachable && latest.version) { + latestVersion = latest.version; + await writeCachedLatest(cachePath, latest.version, now); + } + } + + if (!shouldShowNotice(latestVersion) || latestVersion === undefined) return; + + stderr.write(`${colorize(formatUpdateNotice(latestVersion), stderr)}\n\n`); +} From 14ba13a45388b584d00efeeaa005cbeafeee7d90 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:29:02 -0400 Subject: [PATCH 2/5] throttle update notice --- src/__tests__/utils/update-notice.test.ts | 61 ++++++++++++++++++++++ src/utils/update-notice.ts | 62 +++++++++++++++++++---- 2 files changed, 113 insertions(+), 10 deletions(-) diff --git a/src/__tests__/utils/update-notice.test.ts b/src/__tests__/utils/update-notice.test.ts index 30bd37e4b1..3cbed8ef82 100644 --- a/src/__tests__/utils/update-notice.test.ts +++ b/src/__tests__/utils/update-notice.test.ts @@ -91,6 +91,67 @@ describe('update notice', () => { ); }); + it('does not print the same update twice within 12 hours', async () => { + fs.writeFileSync( + path.join(tmpDir, 'update-check.json'), + JSON.stringify({ + latestVersion: '99.99.99', + checkedAt: new Date('2026-06-04T12:00:00.000Z').toISOString(), + lastShownVersion: '99.99.99', + lastShownAt: new Date('2026-06-04T12:30:00.000Z').toISOString(), + }) + ); + + await maybeShowUpdateNotice({ + cacheDir: tmpDir, + now: new Date('2026-06-04T13:00:00.000Z'), + stderr: stderr(), + }); + + expect(getLatestVersion).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); + + it('prints the same update again after 12 hours', async () => { + fs.writeFileSync( + path.join(tmpDir, 'update-check.json'), + JSON.stringify({ + latestVersion: '99.99.99', + checkedAt: new Date('2026-06-04T12:00:00.000Z').toISOString(), + lastShownVersion: '99.99.99', + lastShownAt: new Date('2026-06-04T00:00:00.000Z').toISOString(), + }) + ); + + await maybeShowUpdateNotice({ + cacheDir: tmpDir, + now: new Date('2026-06-04T13:00:00.000Z'), + stderr: stderr(), + }); + + expect(write).toHaveBeenCalledWith(expect.stringContaining('99.99.99')); + }); + + it('prints a different newer version even inside the cooldown', async () => { + fs.writeFileSync( + path.join(tmpDir, 'update-check.json'), + JSON.stringify({ + latestVersion: '100.0.0', + checkedAt: new Date('2026-06-04T12:00:00.000Z').toISOString(), + lastShownVersion: '99.99.99', + lastShownAt: new Date('2026-06-04T12:30:00.000Z').toISOString(), + }) + ); + + await maybeShowUpdateNotice({ + cacheDir: tmpDir, + now: new Date('2026-06-04T13:00:00.000Z'), + stderr: stderr(), + }); + + expect(write).toHaveBeenCalledWith(expect.stringContaining('100.0.0')); + }); + it('refreshes a stale cache from npm', async () => { vi.mocked(getLatestVersion).mockResolvedValue({ version: '99.99.99', diff --git a/src/utils/update-notice.ts b/src/utils/update-notice.ts index 21ced22c59..be32f1767e 100644 --- a/src/utils/update-notice.ts +++ b/src/utils/update-notice.ts @@ -6,12 +6,15 @@ import { compareVersions, getLatestVersion } from './npm-registry'; const CACHE_FILENAME = 'update-check.json'; const CACHE_TTL_MS = 20 * 60 * 60 * 1000; +const NOTICE_TTL_MS = 12 * 60 * 60 * 1000; const RELEASE_NOTES_URL = 'https://github.com/firecrawl/cli/releases/latest'; const INSTALL_COMMAND = 'npm install -g firecrawl-cli'; interface UpdateCache { latestVersion: string; checkedAt: string; + lastShownVersion?: string; + lastShownAt?: string; } export interface UpdateNoticeOptions { @@ -42,7 +45,13 @@ function supportsColor(stderr: Pick): boolean { async function readCachedLatest( cachePath: string, now: Date -): Promise<{ latestVersion?: string; stale: boolean }> { +): Promise<{ + latestVersion?: string; + checkedAt?: string; + stale: boolean; + lastShownVersion?: string; + lastShownAt?: string; +}> { try { const raw = await fs.readFile(cachePath, 'utf-8'); const parsed = JSON.parse(raw) as Partial; @@ -56,7 +65,13 @@ async function readCachedLatest( const checkedAt = new Date(parsed.checkedAt).getTime(); const stale = !Number.isFinite(checkedAt) || now.getTime() - checkedAt > CACHE_TTL_MS; - return { latestVersion: parsed.latestVersion, stale }; + return { + latestVersion: parsed.latestVersion, + checkedAt: parsed.checkedAt, + stale, + lastShownVersion: parsed.lastShownVersion, + lastShownAt: parsed.lastShownAt, + }; } catch { return { stale: true }; } @@ -64,16 +79,11 @@ async function readCachedLatest( async function writeCachedLatest( cachePath: string, - latestVersion: string, - now: Date + cache: UpdateCache ): Promise { try { await fs.mkdir(path.dirname(cachePath), { recursive: true }); - await fs.writeFile( - cachePath, - `${JSON.stringify({ latestVersion, checkedAt: now.toISOString() })}\n`, - 'utf-8' - ); + await fs.writeFile(cachePath, `${JSON.stringify(cache)}\n`, 'utf-8'); } catch { // Update checks should never make a real command fail. } @@ -110,6 +120,24 @@ function shouldShowNotice(latestVersion: string | undefined): boolean { ); } +function noticeWasRecentlyShown( + cache: { + lastShownVersion?: string; + lastShownAt?: string; + }, + latestVersion: string, + now: Date +): boolean { + if (cache.lastShownVersion !== latestVersion || !cache.lastShownAt) { + return false; + } + + const lastShownAt = new Date(cache.lastShownAt).getTime(); + return ( + Number.isFinite(lastShownAt) && now.getTime() - lastShownAt <= NOTICE_TTL_MS + ); +} + export async function maybeShowUpdateNotice( options: UpdateNoticeOptions = {} ): Promise { @@ -121,15 +149,29 @@ export async function maybeShowUpdateNotice( const cached = await readCachedLatest(cachePath, now); let latestVersion = cached.latestVersion; + let checkedAt = cached.checkedAt; if (cached.stale) { const latest = await getLatestVersion(packageJson.name, 750); if (!latest.unreachable && latest.version) { latestVersion = latest.version; - await writeCachedLatest(cachePath, latest.version, now); + checkedAt = now.toISOString(); + await writeCachedLatest(cachePath, { + latestVersion: latest.version, + checkedAt, + lastShownVersion: cached.lastShownVersion, + lastShownAt: cached.lastShownAt, + }); } } if (!shouldShowNotice(latestVersion) || latestVersion === undefined) return; + if (noticeWasRecentlyShown(cached, latestVersion, now)) return; stderr.write(`${colorize(formatUpdateNotice(latestVersion), stderr)}\n\n`); + await writeCachedLatest(cachePath, { + latestVersion, + checkedAt: checkedAt ?? now.toISOString(), + lastShownVersion: latestVersion, + lastShownAt: now.toISOString(), + }); } From 911070a54346529a2d485271ceec04b52618d441 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:55:08 -0400 Subject: [PATCH 3/5] add make default web setup --- README.md | 13 ++ src/__tests__/commands/setup.test.ts | 28 +++- src/__tests__/utils/web-defaults.test.ts | 111 +++++++++++++ src/commands/init.ts | 3 + src/commands/setup.ts | 31 +++- src/index.ts | 34 +++- src/utils/web-defaults.ts | 203 +++++++++++++++++++++++ 7 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/utils/web-defaults.test.ts create mode 100644 src/utils/web-defaults.ts diff --git a/README.md b/README.md index 3d72d06b83..27a7d42738 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,19 @@ To install the Firecrawl MCP server into your editors (Cursor, Claude Code, VS C firecrawl setup mcp ``` +To make Firecrawl the default web provider for supported AI agents: + +```bash +firecrawl make default +``` + +This disables native web fetch/search where supported so agents route web work +through Firecrawl. To undo those config changes: + +```bash +firecrawl make default --undo +``` + ## Quick Start Just run a command - the CLI will prompt you to authenticate if needed: diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 8485d1fd45..ec39a62f4b 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -1,11 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { execSync } from 'child_process'; -import { handleSetupCommand } from '../../commands/setup'; +import { + handleMakeDefaultCommand, + handleSetupCommand, +} from '../../commands/setup'; +import { configureWebDefaults } from '../../utils/web-defaults'; vi.mock('child_process', () => ({ execSync: vi.fn(), })); +vi.mock('../../utils/web-defaults', () => ({ + configureWebDefaults: vi.fn(async () => []), +})); + describe('handleSetupCommand', () => { beforeEach(() => { vi.clearAllMocks(); @@ -50,6 +58,24 @@ describe('handleSetupCommand', () => { ); }); + it('configures Firecrawl as the default web provider via setup alias', async () => { + await handleSetupCommand('defaults', {}); + + expect(configureWebDefaults).toHaveBeenCalledWith({ undo: undefined }); + }); + + it('configures Firecrawl as the default web provider via make default', async () => { + await handleMakeDefaultCommand({}); + + expect(configureWebDefaults).toHaveBeenCalledWith({ undo: undefined }); + }); + + it('undoes default web provider config', async () => { + await handleMakeDefaultCommand({ undo: true }); + + expect(configureWebDefaults).toHaveBeenCalledWith({ undo: true }); + }); + 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/web-defaults.test.ts b/src/__tests__/utils/web-defaults.test.ts new file mode 100644 index 0000000000..b991d6550d --- /dev/null +++ b/src/__tests__/utils/web-defaults.test.ts @@ -0,0 +1,111 @@ +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { configureWebDefaults } from '../../utils/web-defaults'; + +const originalHome = process.env.HOME; +let tempHome: string; + +async function read(relativePath: string): Promise { + return fs.readFile(path.join(tempHome, relativePath), 'utf8'); +} + +async function write(relativePath: string, content: string): Promise { + const filePath = path.join(tempHome, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, 'utf8'); +} + +describe('configureWebDefaults', () => { + beforeEach(async () => { + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'firecrawl-web-')); + process.env.HOME = tempHome; + }); + + afterEach(async () => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + await fs.rm(tempHome, { recursive: true, force: true }); + }); + + it('disables native Claude Code and Codex web tools', async () => { + const results = await configureWebDefaults(); + + expect(results.map((result) => result.changed)).toEqual([true, true]); + expect(JSON.parse(await read('.claude/settings.json'))).toEqual({ + permissions: { + deny: ['WebSearch', 'WebFetch'], + }, + }); + expect(await read('.codex/config.toml')).toBe('web_search = "disabled"\n'); + }); + + it('preserves existing Claude permissions and Codex root config', async () => { + await write( + '.claude/settings.json', + JSON.stringify({ + permissions: { + allow: ['Read'], + deny: ['Bash(rm *)', 'WebSearch'], + }, + }) + ); + await write( + '.codex/config.toml', + 'model = "gpt-5"\nweb_search = "cached"\n' + ); + + await configureWebDefaults(); + + expect(JSON.parse(await read('.claude/settings.json'))).toEqual({ + permissions: { + allow: ['Read'], + deny: ['Bash(rm *)', 'WebSearch', 'WebFetch'], + }, + }); + expect(await read('.codex/config.toml')).toBe( + 'model = "gpt-5"\nweb_search = "disabled"\n' + ); + }); + + it('writes Codex web_search at the root before TOML tables', async () => { + await write( + '.codex/config.toml', + 'model = "gpt-5"\n\n[mcp_servers.firecrawl]\ncommand = "npx"\n' + ); + + await configureWebDefaults(); + + expect(await read('.codex/config.toml')).toBe( + 'model = "gpt-5"\n\nweb_search = "disabled"\n[mcp_servers.firecrawl]\ncommand = "npx"\n' + ); + }); + + it('undoes only native web defaults', async () => { + await write( + '.claude/settings.json', + JSON.stringify({ + permissions: { + deny: ['Bash(rm *)', 'WebSearch', 'WebFetch'], + }, + }) + ); + await write( + '.codex/config.toml', + 'model = "gpt-5"\n\n[profiles.research]\nweb_search = "disabled"\n' + ); + + const results = await configureWebDefaults({ undo: true }); + + expect(results.map((result) => result.changed)).toEqual([true, false]); + expect(JSON.parse(await read('.claude/settings.json'))).toEqual({ + permissions: { + deny: ['Bash(rm *)'], + }, + }); + expect(await read('.codex/config.toml')).toBe( + 'model = "gpt-5"\n\n[profiles.research]\nweb_search = "disabled"\n' + ); + }); +}); diff --git a/src/commands/init.ts b/src/commands/init.ts index 1bac7fc6e4..c359431d05 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -219,6 +219,9 @@ function printNextSteps(skillCount: number | null): void { console.log( ` ${arrow} ${dim}Add MCP: ${reset} ${bold}firecrawl setup mcp${reset}` ); + console.log( + ` ${arrow} ${dim}Make default:${reset} ${bold}firecrawl make default${reset}` + ); console.log( ` ${arrow} ${dim}All commands:${reset} ${bold}firecrawl --help${reset}` ); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 8176aae18f..df438e7fa0 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -12,12 +12,14 @@ import { WORKFLOW_SKILL_REPOS, } from './skills-install'; import { hasNpx, installSkillsNative } from './skills-native'; +import { configureWebDefaults } from '../utils/web-defaults'; -export type SetupSubcommand = 'skills' | 'workflows' | 'mcp'; +export type SetupSubcommand = 'skills' | 'workflows' | 'mcp' | 'defaults'; export interface SetupOptions { global?: boolean; agent?: string; + undo?: boolean; } /** @@ -37,6 +39,9 @@ export async function handleSetupCommand( case 'mcp': await installMcp(options); break; + case 'defaults': + await handleMakeDefaultCommand(options); + break; default: console.error(`Unknown setup subcommand: ${subcommand}`); console.log('\nAvailable subcommands:'); @@ -49,10 +54,34 @@ export async function handleSetupCommand( console.log( ' mcp Install firecrawl MCP server into editors (Cursor, Claude Code, VS Code, etc.)' ); + console.log( + ' defaults Make Firecrawl the default web provider for supported AI agents' + ); process.exit(1); } } +export async function handleMakeDefaultCommand( + options: SetupOptions = {} +): Promise { + const results = await configureWebDefaults({ undo: options.undo }); + + for (const result of results) { + const prefix = result.skipped ? '!' : result.changed ? '✓' : '•'; + console.log(`${prefix} ${result.message}`); + console.log(` ${result.path}`); + } + + console.log(''); + if (options.undo) { + console.log('Native web tools restored where supported.'); + } else { + console.log( + 'Firecrawl is now the default web provider for supported AI agents.' + ); + } +} + async function installSkills( options: SetupOptions, repos: readonly string[] diff --git a/src/index.ts b/src/index.ts index 2c504202e2..83a0425f15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,7 +43,7 @@ import { scaffoldTemplate, findTemplate, } from './commands/init'; -import { handleSetupCommand } from './commands/setup'; +import { handleMakeDefaultCommand, handleSetupCommand } from './commands/setup'; import type { SetupSubcommand } from './commands/setup'; import { handleEnvPullCommand } from './commands/env'; import { handleStatusCommand } from './commands/status'; @@ -1810,15 +1810,43 @@ program program .command('setup') .description( - 'Set up individual firecrawl integrations (skills, workflows, mcp)' + 'Set up individual firecrawl integrations (skills, workflows, mcp, defaults)' + ) + .argument( + '', + 'What to set up: "skills", "workflows", "mcp", or "defaults"' ) - .argument('', 'What to set up: "skills", "workflows", or "mcp"') .option('-g, --global', 'Install globally (user-level)') .option('-a, --agent ', 'Install to a specific agent') + .option( + '--undo', + 'Undo setup defaults by re-enabling native web tools where supported' + ) .action(async (subcommand: SetupSubcommand, options) => { await handleSetupCommand(subcommand, options); }); +program + .command('make') + .description('Make Firecrawl the default provider for supported workflows') + .argument('', 'What to make default: "default"') + .option( + '--undo', + 'Undo default provider config by re-enabling native web tools where supported' + ) + .action(async (target, options) => { + if (target !== 'default') { + console.error(`Unknown make target: ${target}`); + console.log('\nAvailable targets:'); + console.log( + ' default Make Firecrawl the default web provider for supported AI agents' + ); + process.exit(1); + } + + await handleMakeDefaultCommand(options); + }); + program .command('env') .description('Pull FIRECRAWL_API_KEY into a local .env file') diff --git a/src/utils/web-defaults.ts b/src/utils/web-defaults.ts new file mode 100644 index 0000000000..4f47caa3ca --- /dev/null +++ b/src/utils/web-defaults.ts @@ -0,0 +1,203 @@ +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + +const CLAUDE_DENY_TOOLS = ['WebSearch', 'WebFetch'] as const; +const CODEX_WEB_SEARCH_DISABLED = 'web_search = "disabled"'; + +export interface WebDefaultsOptions { + undo?: boolean; +} + +export interface WebDefaultResult { + agent: 'Claude Code' | 'Codex'; + path: string; + changed: boolean; + skipped?: boolean; + message: string; +} + +async function readText(filePath: string): Promise { + try { + return await fs.readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +async function writeText(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, 'utf8'); +} + +function removeJsonComments(content: string): string { + return content + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:\\])\/\/.*$/gm, '$1'); +} + +async function configureClaudeDefaults( + undo: boolean +): Promise { + const filePath = path.join(os.homedir(), '.claude', 'settings.json'); + const existing = await readText(filePath); + let config: Record = {}; + + if (existing && existing.trim()) { + try { + config = JSON.parse(removeJsonComments(existing)); + } catch { + return { + agent: 'Claude Code', + path: filePath, + changed: false, + skipped: true, + message: + 'Skipped Claude Code settings because settings.json is not valid JSON', + }; + } + } + + const permissions = + config.permissions && typeof config.permissions === 'object' + ? (config.permissions as Record) + : {}; + const deny = Array.isArray(permissions.deny) ? [...permissions.deny] : []; + + let nextDeny: unknown[]; + const denyTools = new Set(CLAUDE_DENY_TOOLS); + if (undo) { + nextDeny = deny.filter( + (tool) => typeof tool !== 'string' || !denyTools.has(tool) + ); + } else { + const existingDeny = new Set( + deny.filter((tool): tool is string => typeof tool === 'string') + ); + nextDeny = [...deny]; + for (const tool of CLAUDE_DENY_TOOLS) { + if (!existingDeny.has(tool)) nextDeny.push(tool); + } + } + + const changed = JSON.stringify(deny) !== JSON.stringify(nextDeny); + if (!changed) { + return { + agent: 'Claude Code', + path: filePath, + changed: false, + message: undo + ? 'Claude Code native WebSearch/WebFetch were already enabled' + : 'Claude Code already denies native WebSearch/WebFetch', + }; + } + + const nextPermissions = { ...permissions }; + if (nextDeny.length > 0) { + nextPermissions.deny = nextDeny; + } else { + delete nextPermissions.deny; + } + + const nextConfig = { ...config }; + if (Object.keys(nextPermissions).length > 0) { + nextConfig.permissions = nextPermissions; + } else { + delete nextConfig.permissions; + } + + await writeText(filePath, `${JSON.stringify(nextConfig, null, 2)}\n`); + + return { + agent: 'Claude Code', + path: filePath, + changed: true, + message: undo + ? 'Enabled Claude Code native WebSearch/WebFetch' + : 'Disabled Claude Code native WebSearch/WebFetch', + }; +} + +function setCodexWebSearchDisabled(content: string): { + content: string; + changed: boolean; +} { + if (content.trim().length === 0) { + return { content: `${CODEX_WEB_SEARCH_DISABLED}\n`, changed: true }; + } + + const lines = content.split(/\r?\n/); + const firstTableIndex = lines.findIndex((line) => /^\s*\[/.test(line)); + const rootEnd = firstTableIndex === -1 ? lines.length : firstTableIndex; + + for (let index = 0; index < rootEnd; index += 1) { + if (/^\s*web_search\s*=/.test(lines[index])) { + if (lines[index] === CODEX_WEB_SEARCH_DISABLED) { + return { content, changed: false }; + } + lines[index] = CODEX_WEB_SEARCH_DISABLED; + return { content: lines.join('\n'), changed: true }; + } + } + + lines.splice(rootEnd, 0, CODEX_WEB_SEARCH_DISABLED); + return { content: lines.join('\n'), changed: true }; +} + +function removeCodexWebSearchDisabled(content: string): { + content: string; + changed: boolean; +} { + const lines = content.split(/\r?\n/); + const firstTableIndex = lines.findIndex((line) => /^\s*\[/.test(line)); + const rootEnd = firstTableIndex === -1 ? lines.length : firstTableIndex; + const nextLines = lines.filter((line, index) => { + if (index >= rootEnd) return true; + return !/^web_search\s*=\s*["']disabled["']\s*(#.*)?$/.test(line.trim()); + }); + const next = nextLines.join('\n').replace(/\n{3,}/g, '\n\n'); + return { content: next, changed: next !== content }; +} + +async function configureCodexDefaults( + undo: boolean +): Promise { + const filePath = path.join(os.homedir(), '.codex', 'config.toml'); + const existing = (await readText(filePath)) ?? ''; + const result = undo + ? removeCodexWebSearchDisabled(existing) + : setCodexWebSearchDisabled(existing); + + if (!result.changed) { + return { + agent: 'Codex', + path: filePath, + changed: false, + message: undo + ? 'Codex native web search was already enabled' + : 'Codex native web search was already disabled', + }; + } + + await writeText(filePath, result.content); + + return { + agent: 'Codex', + path: filePath, + changed: true, + message: undo + ? 'Enabled Codex native web search' + : 'Disabled Codex native web search', + }; +} + +export async function configureWebDefaults( + options: WebDefaultsOptions = {} +): Promise { + const undo = Boolean(options.undo); + return Promise.all([ + configureClaudeDefaults(undo), + configureCodexDefaults(undo), + ]); +} From d239d22b60adca2dd13c358045a1f6683ea78e81 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:05:23 -0400 Subject: [PATCH 4/5] fix update-notice test for colorized output The notice wraps "Update available!" in ANSI codes on a TTY, so the contiguous substring assertion failed in CI. Assert on the version portion that survives colorization instead. Claude-Session: https://claude.ai/code/session_016Szz5ZgWNULKbvNGqt8sdm --- src/__tests__/utils/update-notice.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/utils/update-notice.test.ts b/src/__tests__/utils/update-notice.test.ts index 3cbed8ef82..58546077e7 100644 --- a/src/__tests__/utils/update-notice.test.ts +++ b/src/__tests__/utils/update-notice.test.ts @@ -84,10 +84,10 @@ describe('update notice', () => { }); expect(getLatestVersion).not.toHaveBeenCalled(); + // The notice colorizes "Update available!" with ANSI codes on a TTY, so + // assert on the (uncolorized) version portion that survives formatting. expect(write).toHaveBeenCalledWith( - expect.stringContaining( - `Update available! ${packageJson.version} -> 99.99.99` - ) + expect.stringContaining(`${packageJson.version} -> 99.99.99`) ); }); From aa7c6d0439c6e8a49022da7b8e72bea2d4a9e890 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:15:08 -0400 Subject: [PATCH 5/5] bump version to 1.19.16 Claude-Session: https://claude.ai/code/session_016Szz5ZgWNULKbvNGqt8sdm --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 367b300a51..2d4b10809f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.19.15", + "version": "1.19.16", "description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.", "main": "dist/index.js", "bin": {