diff --git a/package.json b/package.json index ba305e687..7585b49b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.19.17", + "version": "1.19.18", "description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.", "main": "dist/index.js", "bin": { diff --git a/src/__tests__/commands/launch.test.ts b/src/__tests__/commands/launch.test.ts index b422f2ca4..ecfaa448b 100644 --- a/src/__tests__/commands/launch.test.ts +++ b/src/__tests__/commands/launch.test.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'child_process'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { select } from '@inquirer/prompts'; import { handleLaunchCommand } from '../../commands/launch'; import { installHermesMcp, @@ -7,11 +8,16 @@ import { installOpenClawMcp, installSkillsForAgent, } from '../../commands/setup'; +import { ALL_SKILL_REPOS } from '../../commands/skills-install'; vi.mock('child_process', () => ({ spawnSync: vi.fn(), })); +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), +})); + vi.mock('../../commands/setup', () => ({ installHermesMcp: vi.fn(async () => undefined), installMcp: vi.fn(async () => undefined), @@ -20,11 +26,38 @@ vi.mock('../../commands/setup', () => ({ })); describe('handleLaunchCommand', () => { + const originalIsTty = process.stdin.isTTY; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(spawnSync).mockReturnValue({ status: 0 } as never); + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: false, + }); }); + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: originalIsTty, + }); + }); + + function setStdinTty(value: boolean): () => void { + const originalIsTty = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value, + }); + return () => { + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: originalIsTty, + }); + }; + } + it('installs Claude Code MCP without launching in install mode', async () => { await handleLaunchCommand('claude', { install: true }); @@ -32,11 +65,18 @@ describe('handleLaunchCommand', () => { agent: 'claude-code', global: true, yes: true, + quiet: true, }); - expect(installSkillsForAgent).toHaveBeenCalledWith('claude-code', { - global: true, - yes: true, - }); + expect(installSkillsForAgent).toHaveBeenCalledWith( + 'claude-code', + { + global: true, + yes: true, + nativeSkills: true, + quiet: true, + }, + ALL_SKILL_REPOS + ); expect(spawnSync).not.toHaveBeenCalled(); }); @@ -56,6 +96,7 @@ describe('handleLaunchCommand', () => { agent: 'vscode', global: true, yes: true, + quiet: true, }); expect(installSkillsForAgent).not.toHaveBeenCalled(); expect(spawnSync).toHaveBeenNthCalledWith(1, 'code', ['--version'], { @@ -76,11 +117,18 @@ describe('handleLaunchCommand', () => { agent: 'codex', global: true, yes: true, + quiet: true, }); - expect(installSkillsForAgent).toHaveBeenCalledWith('codex', { - global: true, - yes: true, - }); + expect(installSkillsForAgent).toHaveBeenCalledWith( + 'codex', + { + global: true, + yes: true, + nativeSkills: true, + quiet: true, + }, + ALL_SKILL_REPOS + ); expect(spawnSync).toHaveBeenNthCalledWith( 2, 'codex', @@ -89,18 +137,74 @@ describe('handleLaunchCommand', () => { ); }); - it('configures Codex MCP and opens Codex App separately from the CLI', async () => { - await handleLaunchCommand('codex-app'); + it('asks which Codex setup to run and can install MCP only', async () => { + const restoreStdin = setStdinTty(true); + vi.mocked(select).mockResolvedValue('mcp'); + + try { + await handleLaunchCommand('codex', { install: true }); + } finally { + restoreStdin(); + } + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Configure Firecrawl for Codex', + }) + ); expect(installMcp).toHaveBeenCalledWith({ agent: 'codex', global: true, yes: true, + quiet: true, }); - expect(installSkillsForAgent).toHaveBeenCalledWith('codex', { + expect(installSkillsForAgent).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + }); + + it('asks which Codex setup to run and can install CLI skills only', async () => { + const restoreStdin = setStdinTty(true); + vi.mocked(select).mockResolvedValue('skills'); + + try { + await handleLaunchCommand('codex', { install: true }); + } finally { + restoreStdin(); + } + + expect(installMcp).not.toHaveBeenCalled(); + expect(installSkillsForAgent).toHaveBeenCalledWith( + 'codex', + { + global: true, + yes: true, + nativeSkills: true, + quiet: true, + }, + ALL_SKILL_REPOS + ); + expect(spawnSync).not.toHaveBeenCalled(); + }); + + it('configures Codex MCP and opens Codex App separately from the CLI', async () => { + await handleLaunchCommand('codex-app'); + + expect(installMcp).toHaveBeenCalledWith({ + agent: 'codex', global: true, yes: true, + quiet: true, }); + expect(installSkillsForAgent).toHaveBeenCalledWith( + 'codex', + { + global: true, + yes: true, + nativeSkills: true, + quiet: true, + }, + ALL_SKILL_REPOS + ); expect(spawnSync).toHaveBeenNthCalledWith(1, 'open', ['--version'], { stdio: 'ignore', }); @@ -122,10 +226,16 @@ describe('handleLaunchCommand', () => { await handleLaunchCommand('opencode', { skipMcp: true }); expect(installMcp).not.toHaveBeenCalled(); - expect(installSkillsForAgent).toHaveBeenCalledWith('opencode', { - global: true, - yes: true, - }); + expect(installSkillsForAgent).toHaveBeenCalledWith( + 'opencode', + { + global: true, + yes: true, + nativeSkills: true, + quiet: true, + }, + ALL_SKILL_REPOS + ); expect(spawnSync).toHaveBeenNthCalledWith(1, 'opencode', ['--version'], { stdio: 'ignore', }); @@ -142,10 +252,16 @@ describe('handleLaunchCommand', () => { await handleLaunchCommand('hermes'); expect(installHermesMcp).toHaveBeenCalled(); - expect(installSkillsForAgent).toHaveBeenCalledWith('hermes-agent', { - global: true, - yes: true, - }); + expect(installSkillsForAgent).toHaveBeenCalledWith( + 'hermes-agent', + { + global: true, + yes: true, + nativeSkills: true, + quiet: true, + }, + ALL_SKILL_REPOS + ); expect(spawnSync).toHaveBeenNthCalledWith(1, 'hermes', ['--version'], { stdio: 'ignore', }); @@ -161,10 +277,16 @@ describe('handleLaunchCommand', () => { await handleLaunchCommand('openclaw'); expect(installOpenClawMcp).toHaveBeenCalled(); - expect(installSkillsForAgent).toHaveBeenCalledWith('openclaw', { - global: true, - yes: true, - }); + expect(installSkillsForAgent).toHaveBeenCalledWith( + 'openclaw', + { + global: true, + yes: true, + nativeSkills: true, + quiet: true, + }, + ALL_SKILL_REPOS + ); expect(spawnSync).toHaveBeenNthCalledWith(1, 'openclaw', ['--version'], { stdio: 'ignore', }); @@ -184,21 +306,14 @@ describe('handleLaunchCommand', () => { }); it('requires an explicit target in non-interactive mode', async () => { - const originalIsTty = process.stdin.isTTY; - Object.defineProperty(process.stdin, 'isTTY', { - configurable: true, - value: false, - }); + const restoreStdin = setStdinTty(false); try { await expect(handleLaunchCommand()).rejects.toThrow( 'Launch target is required in non-interactive mode' ); } finally { - Object.defineProperty(process.stdin, 'isTTY', { - configurable: true, - value: originalIsTty, - }); + restoreStdin(); } }); }); diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 9de35141d..6c989be73 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -8,7 +8,9 @@ import { handleSetupCommand, installHermesMcp, installOpenClawMcp, + installSkillsForAgent, } from '../../commands/setup'; +import { ALL_SKILL_REPOS } from '../../commands/skills-install'; import { configureWebDefaults } from '../../utils/web-defaults'; import { getApiKey } from '../../utils/config'; @@ -74,6 +76,27 @@ describe('handleSetupCommand', () => { ); }); + it('installs all skill repos for Codex non-interactively', async () => { + await installSkillsForAgent( + 'codex', + { global: true, yes: true }, + ALL_SKILL_REPOS + ); + + expect(execSync).toHaveBeenCalledWith( + 'npx -y skills add firecrawl/cli --full-depth --global --yes --agent codex', + expect.objectContaining({ stdio: 'inherit' }) + ); + expect(execSync).toHaveBeenCalledWith( + 'npx -y skills add firecrawl/skills --full-depth --global --yes --agent codex', + expect.objectContaining({ stdio: 'inherit' }) + ); + expect(execSync).toHaveBeenCalledWith( + 'npx -y skills add firecrawl/firecrawl-workflows --full-depth --global --yes --agent codex', + expect.objectContaining({ stdio: 'inherit' }) + ); + }); + it('configures Firecrawl as the default web provider via make default', async () => { await handleMakeDefaultCommand({ yes: true }); @@ -87,11 +110,11 @@ describe('handleSetupCommand', () => { await handleSetupCommand(undefined, { yes: true }); expect(execSync).toHaveBeenCalledWith( - 'npx -y skills add firecrawl/cli --full-depth --global --all', + 'npx -y skills add firecrawl/cli --full-depth --global --all --yes', expect.objectContaining({ stdio: 'inherit' }) ); expect(execSync).toHaveBeenCalledWith( - 'npx -y skills add firecrawl/skills --full-depth --global --all', + 'npx -y skills add firecrawl/skills --full-depth --global --all --yes', expect.objectContaining({ stdio: 'inherit' }) ); expect(execSync).toHaveBeenCalledWith( diff --git a/src/commands/launch.ts b/src/commands/launch.ts index abeda3e66..ee1f11e51 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -9,6 +9,7 @@ import { installOpenClawMcp, installSkillsForAgent, } from './setup'; +import { ALL_SKILL_REPOS } from './skills-install'; export interface LaunchOptions { config?: boolean; @@ -32,6 +33,8 @@ interface LaunchTarget { fallbackCommand?: () => { command: string; args: string[] } | null; } +type LaunchSetupMode = 'both' | 'mcp' | 'skills'; + const TARGETS: LaunchTarget[] = [ { aliases: ['claude', 'claude-code'], @@ -163,6 +166,32 @@ async function pickLaunchTarget(): Promise { ); } +async function pickLaunchSetupMode( + target: LaunchTarget +): Promise { + const { select } = await import('@inquirer/prompts'); + return select({ + message: `Configure Firecrawl for ${target.displayName}`, + choices: [ + { + name: 'MCP + CLI skills', + value: 'both', + description: 'Configure tools and install all Firecrawl skills', + }, + { + name: 'MCP only', + value: 'mcp', + description: 'Only configure the Firecrawl MCP server', + }, + { + name: 'CLI skills only', + value: 'skills', + description: 'Only install Firecrawl skills for this agent', + }, + ], + }); +} + function commandExists(command: string): boolean { const result = spawnSync(command, ['--version'], { stdio: 'ignore' }); return ( @@ -216,23 +245,47 @@ export async function handleLaunchCommand( ); } - if (!options.skipMcp) { + const targetSupportsMcp = Boolean(target.mcpInstaller || target.mcpAgent); + const targetSupportsSkills = Boolean(target.skillsAgent); + let installMcpForTarget = targetSupportsMcp && !options.skipMcp; + let installSkillsForTarget = targetSupportsSkills && !options.skipSkills; + + if ( + installMcpForTarget && + installSkillsForTarget && + !options.yes && + process.stdin.isTTY + ) { + const setupMode = await pickLaunchSetupMode(target); + installMcpForTarget = setupMode === 'both' || setupMode === 'mcp'; + installSkillsForTarget = setupMode === 'both' || setupMode === 'skills'; + } + + if (installMcpForTarget) { if (target.mcpInstaller) { await target.mcpInstaller(); } else if (target.mcpAgent) { await installMcp({ agent: target.mcpAgent, global: options.global !== false, - yes: options.yes ?? true, + yes: true, + quiet: true, }); } } - if (target.skillsAgent && !options.skipSkills) { - await installSkillsForAgent(target.skillsAgent, { - global: options.global !== false, - yes: options.yes ?? true, - }); + if (target.skillsAgent && installSkillsForTarget) { + console.log(`Installing Firecrawl skills for ${target.displayName}...`); + await installSkillsForAgent( + target.skillsAgent, + { + global: options.global !== false, + yes: true, + nativeSkills: true, + quiet: true, + }, + ALL_SKILL_REPOS + ); } if (options.config || options.install || options.setup) { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 125c8eb2b..25eac9450 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -39,6 +39,24 @@ export interface SetupOptions { undo?: boolean; /** Skip the interactive harness picker and apply to all agents. */ yes?: boolean; + /** Use the built-in skill installer instead of shelling out to npx skills. */ + nativeSkills?: boolean; + /** Render compact skill install output. */ + quiet?: boolean; +} + +const green = '\x1b[32m'; +const dim = '\x1b[2m'; +const reset = '\x1b[0m'; + +const SKILL_REPO_LABELS: Record = { + 'firecrawl/cli': 'Core CLI skills', + 'firecrawl/skills': 'Build skills', + 'firecrawl/firecrawl-workflows': 'Workflow skills', +}; + +function skillRepoLabel(repo: string): string { + return SKILL_REPO_LABELS[repo] ?? repo; } function shellQuote(value: string): string { @@ -281,11 +299,33 @@ async function installSkills( repos: readonly string[] ): Promise { for (const repo of repos) { + if (options.nativeSkills) { + try { + const result = await installSkillsNative(repo, { + agent: options.agent, + quiet: options.quiet, + }); + if (options.quiet) { + console.log( + ` ${green}✓${reset} ${skillRepoLabel(repo)} ${dim}(${result.skillCount})${reset}` + ); + } + } catch (error) { + console.error( + `Failed to install skills from ${repo}:`, + error instanceof Error ? error.message : 'Unknown error' + ); + process.exit(1); + } + continue; + } + if (hasNpx()) { const args = buildSkillsInstallArgs({ repo, agent: options.agent, global: true, + yes: options.yes, includeNpxYes: true, }); @@ -379,13 +419,23 @@ async function installAddMcp( } const cmd = args.join(' '); - console.log(`Running: ${cmd}\n`); + if (!options.quiet) { + console.log(`Running: ${cmd}\n`); + } try { execSync(cmd, { 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 { process.exit(1); } diff --git a/src/commands/skills-native.ts b/src/commands/skills-native.ts index bae2d1f2a..a9ad9ba17 100644 --- a/src/commands/skills-native.ts +++ b/src/commands/skills-native.ts @@ -24,6 +24,18 @@ interface AgentConfig { detectDir: string; } +export interface NativeSkillsInstallOptions { + /** Link skills only into this agent's global skills directory. */ + agent?: string; + /** Suppress per-repo status lines; caller will render its own summary. */ + quiet?: boolean; +} + +export interface NativeSkillsInstallResult { + skillCount: number; + linkedAgents: string[]; +} + const AGENTS: AgentConfig[] = [ { name: 'claude-code', @@ -143,6 +155,11 @@ function parseFrontmatter(content: string): { return { name: result.name, description: result.description }; } +function resolveAgentConfig(agent: string): AgentConfig | undefined { + const normalized = agent.trim().toLowerCase(); + return AGENTS.find((candidate) => candidate.name === normalized); +} + /** * Discover all skills in a directory tree by finding SKILL.md files. */ @@ -328,8 +345,9 @@ function cloneRepo(tmpDir: string, repo: string): void { * Replicates: npx skills add --full-depth --global --all */ export async function installSkillsNative( - repo: string = DEFAULT_REPO -): Promise { + repo: string = DEFAULT_REPO, + options: NativeSkillsInstallOptions = {} +): Promise { const home = os.homedir(); const canonicalBase = path.join(home, CANONICAL_DIR); const lockFilePath = path.join(home, LOCK_FILE); @@ -339,9 +357,11 @@ export async function installSkillsNative( const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'firecrawl-skills-')); try { - console.log( - ` ${dim}Downloading skills from github.com/${repo}...${reset}` - ); + if (!options.quiet) { + console.log( + ` ${dim}Downloading skills from github.com/${repo}...${reset}` + ); + } cloneRepo(tmpDir, repo); // Discover skills @@ -355,7 +375,9 @@ export async function installSkillsNative( throw new Error('No skills found in repository'); } - console.log(` ${dim}Found ${skills.length} skills${reset}`); + if (!options.quiet) { + console.log(` ${dim}Found ${skills.length} skills${reset}`); + } // Copy skills to canonical directory fs.mkdirSync(canonicalBase, { recursive: true }); @@ -371,7 +393,15 @@ export async function installSkillsNative( } // Detect installed agents and create symlinks - const agents = detectInstalledAgents(); + const agents = options.agent + ? [ + resolveAgentConfig(options.agent) ?? { + name: options.agent, + globalSkillsDir: path.join(`.${options.agent}`, 'skills'), + detectDir: `.${options.agent}`, + }, + ] + : detectInstalledAgents(); const linkedAgents: string[] = []; for (const agent of agents) { @@ -448,12 +478,18 @@ export async function installSkillsNative( fs.writeFileSync(lockFilePath, JSON.stringify(lock, null, 2) + '\n'); // Summary - console.log( - ` ${green}✓${reset} ${skills.length} skills installed to ${dim}~/${CANONICAL_DIR}/${reset}` - ); - if (linkedAgents.length > 0) { - console.log(` ${green}✓${reset} Linked to: ${linkedAgents.join(', ')}`); + if (!options.quiet) { + console.log( + ` ${green}✓${reset} ${skills.length} skills installed to ${dim}~/${CANONICAL_DIR}/${reset}` + ); + if (linkedAgents.length > 0) { + console.log( + ` ${green}✓${reset} Linked to: ${linkedAgents.join(', ')}` + ); + } } + + return { skillCount: skills.length, linkedAgents }; } finally { // Clean up temp directory try { diff --git a/src/index.ts b/src/index.ts index 4fd1e7de4..8949f0732 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2235,7 +2235,7 @@ program 'Install Firecrawl MCP globally for the selected agent', true ) - .option('-y, --yes', 'Skip MCP installer confirmation prompts', true) + .option('-y, --yes', 'Skip setup picker and installer confirmation prompts') .allowUnknownOption() .action(async (agent: string, args: string[], options) => { await handleLaunchCommand(agent, options, args);