diff --git a/README.md b/README.md index 9d31fe2a8..a4fb188ca 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,36 @@ To install the Firecrawl MCP server into your editors (Cursor, Claude Code, VS C firecrawl setup mcp ``` +### Project routing for Claude Code and Codex CLI + +An authenticated `firecrawl launch` that successfully configures both MCP and +skills also installs the tested Firecrawl routing state for Claude Code or the +Codex CLI. It adds an exact, versioned managed block to the project's +`CLAUDE.md` or `AGENTS.md` and creates project-local routed copies of the +installed Firecrawl skills. Existing user-owned skills are never overwritten. + +```bash +# Configure the current project without starting the agent +firecrawl launch claude --install --yes +firecrawl launch codex --install --yes + +# Configure and launch from an explicit project +firecrawl launch codex --project ./my-project + +# Permanently opt out, or re-enable later +firecrawl launch claude --no-router-card --project ./my-project +firecrawl launch claude --router-card --project ./my-project + +# Remove managed routing and exit (also persists the opt-out) +firecrawl launch codex --remove-router-card --project ./my-project +``` + +The state is project-scoped: Claude uses `CLAUDE.md` and `.claude/skills`; +Codex CLI uses `AGENTS.md` and `.agents/skills`. The CLI refuses home-folder, +filesystem-root, symlink, modified-managed-skill, and user-owned-skill writes. +Package installation, MCP-only setup, skills-only setup, keyless setup, +unsupported agents, and Codex App do not install project router state. + To make Firecrawl the default web provider for supported AI agents: ```bash diff --git a/src/__tests__/commands/launch.test.ts b/src/__tests__/commands/launch.test.ts index ecfaa448b..2137daefe 100644 --- a/src/__tests__/commands/launch.test.ts +++ b/src/__tests__/commands/launch.test.ts @@ -9,6 +9,12 @@ import { installSkillsForAgent, } from '../../commands/setup'; import { ALL_SKILL_REPOS } from '../../commands/skills-install'; +import { getApiKey } from '../../utils/config'; +import { resolveRouterCardProject } from '../../utils/router-card'; +import { + installFullProjectRouterState, + removeFullProjectRouterState, +} from '../../utils/project-router-state'; vi.mock('child_process', () => ({ spawnSync: vi.fn(), @@ -25,12 +31,120 @@ vi.mock('../../commands/setup', () => ({ installSkillsForAgent: vi.fn(async () => undefined), })); +vi.mock('../../utils/config', () => ({ + getApiKey: vi.fn(() => 'fc-test-key'), +})); + +vi.mock('../../utils/router-card', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + resolveRouterCardProject: vi.fn( + (project?: string) => project ?? process.cwd() + ), + }; +}); + +vi.mock('../../utils/project-router-state', () => ({ + installFullProjectRouterState: vi.fn((options) => ({ + operation: 'install', + status: 'installed', + agent: options.agent, + project: options.project, + operationComplete: true, + artifactsConfigured: true, + nativeDiscovery: { status: 'pending', verified: false }, + card: { + path: `${options.project}/AGENTS.md`, + changed: true, + version: 1, + sha256: + 'df867193a6fe011342fce14b770e497cf667ca755e396bb16bbb52c513627951', + }, + skills: { + root: `${options.project}/.agents/skills`, + changed: true, + sourceCount: 32, + installed: [], + refreshed: [], + current: [], + pruned: [], + removed: [], + preserved: [], + }, + preference: { + path: `${options.project}/.firecrawl/router-card.json`, + enabled: true, + changed: false, + }, + })), + removeFullProjectRouterState: vi.fn((agent, project) => ({ + operation: 'remove', + status: 'removed', + agent, + project, + operationComplete: true, + artifactsConfigured: false, + nativeDiscovery: { status: 'unverified', verified: false }, + card: { + path: `${project}/AGENTS.md`, + changed: true, + version: 1, + sha256: + 'df867193a6fe011342fce14b770e497cf667ca755e396bb16bbb52c513627951', + }, + skills: { + root: `${project}/.agents/skills`, + changed: true, + sourceCount: 0, + installed: [], + refreshed: [], + current: [], + pruned: [], + removed: [{ skillName: 'firecrawl-test' }], + preserved: [], + }, + preference: { + path: `${project}/.firecrawl/router-card.json`, + enabled: false, + changed: true, + }, + })), + skippedFullProjectRouterState: vi.fn((agent, project, warning) => ({ + operation: 'install', + status: 'skipped-safe', + agent, + project, + operationComplete: false, + artifactsConfigured: false, + nativeDiscovery: { status: 'unverified', verified: false }, + warning, + skills: { + root: `${project}/.agents/skills`, + changed: false, + sourceCount: 0, + installed: [], + refreshed: [], + current: [], + pruned: [], + removed: [], + preserved: [], + }, + preference: { + path: `${project}/.firecrawl/router-card.json`, + changed: false, + }, + })), +})); + describe('handleLaunchCommand', () => { const originalIsTty = process.stdin.isTTY; beforeEach(() => { vi.clearAllMocks(); vi.mocked(spawnSync).mockReturnValue({ status: 0 } as never); + vi.mocked(getApiKey).mockReturnValue('fc-test-key'); Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false, @@ -59,7 +173,7 @@ describe('handleLaunchCommand', () => { } it('installs Claude Code MCP without launching in install mode', async () => { - await handleLaunchCommand('claude', { install: true }); + const receipt = await handleLaunchCommand('claude', { install: true }); expect(installMcp).toHaveBeenCalledWith({ agent: 'claude-code', @@ -78,6 +192,18 @@ describe('handleLaunchCommand', () => { ALL_SKILL_REPOS ); expect(spawnSync).not.toHaveBeenCalled(); + expect(installFullProjectRouterState).toHaveBeenCalledWith({ + agent: 'claude', + project: process.cwd(), + authenticated: true, + mcpInstalled: true, + skillsInstalled: true, + forceEnable: false, + }); + expect(receipt).toMatchObject({ + artifactsConfigured: true, + nativeDiscovery: { status: 'pending', verified: false }, + }); }); it('supports setup and config as install-mode aliases', async () => { @@ -214,6 +340,7 @@ describe('handleLaunchCommand', () => { ['-b', 'com.openai.codex'], expect.objectContaining({ stdio: 'inherit' }) ); + expect(installFullProjectRouterState).not.toHaveBeenCalled(); }); it('does not pass extra arguments to Codex App', async () => { @@ -305,6 +432,210 @@ describe('handleLaunchCommand', () => { expect(installSkillsForAgent).not.toHaveBeenCalled(); }); + it('installs the accepted state only after Claude and Codex CLI full setup', async () => { + await handleLaunchCommand('codex', { + project: '/tmp/project', + install: true, + }); + + expect(installMcp).toHaveBeenCalledBefore(vi.mocked(installSkillsForAgent)); + expect(installSkillsForAgent).toHaveBeenCalledBefore( + vi.mocked(installFullProjectRouterState) + ); + expect(installFullProjectRouterState).toHaveBeenCalledWith({ + agent: 'codex', + project: '/tmp/project', + authenticated: true, + mcpInstalled: true, + skillsInstalled: true, + forceEnable: false, + }); + }); + + it('does not install project routing for keyless, MCP-only, or skills-only states', async () => { + vi.mocked(getApiKey).mockReturnValueOnce(undefined); + await handleLaunchCommand('codex', { install: true }); + + const restoreStdin = setStdinTty(true); + vi.mocked(select) + .mockResolvedValueOnce('mcp') + .mockResolvedValueOnce('skills'); + try { + await handleLaunchCommand('claude', { install: true }); + await handleLaunchCommand('claude', { install: true }); + } finally { + restoreStdin(); + } + + expect(installFullProjectRouterState).not.toHaveBeenCalled(); + }); + + it('persists opt-out and continues ordinary setup without reinstalling routing', async () => { + await handleLaunchCommand('codex', { + project: '/tmp/project', + install: true, + routerCard: false, + }); + + expect(removeFullProjectRouterState).toHaveBeenCalledWith( + 'codex', + '/tmp/project' + ); + expect(installMcp).toHaveBeenCalled(); + expect(installSkillsForAgent).toHaveBeenCalled(); + expect(installFullProjectRouterState).not.toHaveBeenCalled(); + }); + + it('removes project routing and exits before setup or launch', async () => { + await handleLaunchCommand('claude', { + project: '/tmp/project', + removeRouterCard: true, + }); + + expect(removeFullProjectRouterState).toHaveBeenCalledWith( + 'claude', + '/tmp/project' + ); + expect(installMcp).not.toHaveBeenCalled(); + expect(installSkillsForAgent).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + }); + + it('explicitly re-enables a persisted opt-out', async () => { + await handleLaunchCommand('codex', { + project: '/tmp/project', + install: true, + routerCard: true, + }); + + expect(installFullProjectRouterState).toHaveBeenCalledWith( + expect.objectContaining({ forceEnable: true }) + ); + }); + + it('rejects contradictory or incomplete explicit router flags before installers run', async () => { + await expect( + handleLaunchCommand('codex', { + routerCard: true, + removeRouterCard: true, + }) + ).rejects.toThrow('Cannot combine'); + await expect( + handleLaunchCommand('codex', { routerCard: true, skipMcp: true }) + ).rejects.toThrow('requires MCP and skills'); + await expect( + handleLaunchCommand('codex', { routerCard: true, skipSkills: true }) + ).rejects.toThrow('requires MCP and skills'); + + expect(installMcp).not.toHaveBeenCalled(); + expect(installSkillsForAgent).not.toHaveBeenCalled(); + expect(installFullProjectRouterState).not.toHaveBeenCalled(); + }); + + it('rejects explicit router setup without auth before installer side effects', async () => { + vi.mocked(getApiKey).mockReturnValue(undefined); + + await expect( + handleLaunchCommand('claude', { routerCard: true }) + ).rejects.toThrow('requires an API key'); + + expect(installMcp).not.toHaveBeenCalled(); + expect(installSkillsForAgent).not.toHaveBeenCalled(); + }); + + it.each([ + ['ordinary', {}], + ['default-on', { install: true }], + ['keyless', { install: true }], + ])( + 'rejects an invalid supplied project before %s installer side effects', + async (_case, launchOptions) => { + if (_case === 'keyless') { + vi.mocked(getApiKey).mockReturnValue(undefined); + } + vi.mocked(resolveRouterCardProject).mockImplementationOnce(() => { + throw new Error('Router card project is not a directory'); + }); + + await expect( + handleLaunchCommand('codex', { + ...launchOptions, + project: '/missing/project', + }) + ).rejects.toThrow('project is not a directory'); + + expect(installMcp).not.toHaveBeenCalled(); + expect(installSkillsForAgent).not.toHaveBeenCalled(); + expect(installFullProjectRouterState).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + } + ); + + it('warns and continues ordinary launch when implicit router delivery is unsafe', async () => { + vi.mocked(installFullProjectRouterState).mockImplementationOnce(() => { + throw new Error('user-owned router skill collision'); + }); + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const receipt = await handleLaunchCommand('codex'); + + expect(receipt).toMatchObject({ + status: 'skipped-safe', + artifactsConfigured: false, + warning: 'user-owned router skill collision', + }); + expect(receipt?.preference).not.toHaveProperty('enabled'); + expect(warning).toHaveBeenCalledWith( + expect.stringContaining('Ordinary Codex setup will continue') + ); + expect(spawnSync).toHaveBeenCalledWith( + 'codex', + [], + expect.objectContaining({ stdio: 'inherit' }) + ); + warning.mockRestore(); + }); + + it('fails hard when explicitly requested router delivery is unsafe', async () => { + vi.mocked(installFullProjectRouterState).mockImplementationOnce(() => { + throw new Error('user-owned router skill collision'); + }); + + await expect( + handleLaunchCommand('codex', { routerCard: true }) + ).rejects.toThrow('user-owned router skill collision'); + + expect(spawnSync).not.toHaveBeenCalled(); + }); + + it('rejects router flags for Codex App and other unvalidated targets', async () => { + await expect( + handleLaunchCommand('codex-app', { routerCard: true }) + ).rejects.toThrow('Claude and the Codex CLI'); + await expect( + handleLaunchCommand('opencode', { removeRouterCard: true }) + ).rejects.toThrow('Claude and the Codex CLI'); + }); + + it('does not create project routing after MCP failure', async () => { + vi.mocked(installMcp).mockRejectedValueOnce(new Error('MCP failed')); + + await expect(handleLaunchCommand('codex')).rejects.toThrow('MCP failed'); + expect(installSkillsForAgent).not.toHaveBeenCalled(); + expect(installFullProjectRouterState).not.toHaveBeenCalled(); + }); + + it('does not create project routing after skills setup fails', async () => { + vi.mocked(installSkillsForAgent).mockRejectedValueOnce( + new Error('Skills failed') + ); + + await expect(handleLaunchCommand('claude')).rejects.toThrow( + 'Skills failed' + ); + expect(installFullProjectRouterState).not.toHaveBeenCalled(); + }); + it('requires an explicit target in non-interactive mode', async () => { const restoreStdin = setStdinTty(false); diff --git a/src/__tests__/utils/project-router-state.test.ts b/src/__tests__/utils/project-router-state.test.ts index 2b5013514..48bbf8b6f 100644 --- a/src/__tests__/utils/project-router-state.test.ts +++ b/src/__tests__/utils/project-router-state.test.ts @@ -8,12 +8,15 @@ import { symlinkSync, writeFileSync, } from 'fs'; +import { createHash } from 'crypto'; +import fs from 'fs'; import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { installFullProjectRouterState, removeFullProjectRouterState, + skippedFullProjectRouterState, } from '../../utils/project-router-state'; import { ROUTER_CARD_SHA256, @@ -23,8 +26,50 @@ import { let home: string; let project: string; +let lockSkills: Record>; -function canonicalSkill(name: string, description = 'Original.'): string { +function writeSkillLock(): void { + const lockPath = path.join(home, '.agents', '.skill-lock.json'); + mkdirSync(path.dirname(lockPath), { recursive: true }); + writeFileSync( + lockPath, + `${JSON.stringify({ version: 3, skills: lockSkills }, null, 2)}\n` + ); +} + +function skillFolderHash(directory: string): string { + const hash = createHash('sha1'); + const walk = (current: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true }).sort( + (a, b) => a.name.localeCompare(b.name) + )) { + if (entry.name.startsWith('.')) continue; + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(absolute); + } else { + hash.update(entry.name); + hash.update(readFileSync(absolute)); + } + } + }; + walk(directory); + return hash.digest('hex'); +} + +function refreshLockedSkillHash(name: string): void { + lockSkills[name].skillFolderHash = skillFolderHash( + path.join(home, '.agents', 'skills', name) + ); + writeSkillLock(); +} + +function canonicalSkill( + name: string, + description = 'Original.', + source = 'firecrawl/cli', + sourceUrl = `https://github.com/${source}.git` +): string { const directory = path.join(home, '.agents', 'skills', name); mkdirSync(directory, { recursive: true }); writeFileSync( @@ -32,6 +77,13 @@ function canonicalSkill(name: string, description = 'Original.'): string { `---\nname: ${name}\ndescription: ${description}\n---\nBody\n` ); writeFileSync(path.join(directory, 'reference.txt'), `${name}\n`); + lockSkills[name] = { + source, + sourceUrl, + skillPath: `skills/${name}/SKILL.md`, + skillFolderHash: skillFolderHash(directory), + }; + writeSkillLock(); return directory; } @@ -51,6 +103,7 @@ beforeEach(() => { project = path.join(home, 'project'); mkdirSync(project); vi.stubEnv('HOME', home); + lockSkills = {}; canonicalSkill('firecrawl-alpha'); canonicalSkill('firecrawl-beta'); }); @@ -75,7 +128,9 @@ describe('full project router state', () => { status: 'installed', agent, project, - complete: true, + operationComplete: true, + artifactsConfigured: true, + nativeDiscovery: { status: 'pending', verified: false }, card: { sha256: ROUTER_CARD_SHA256, changed: true }, skills: { sourceCount: 2, changed: true }, preference: { enabled: true }, @@ -95,6 +150,13 @@ describe('full project router state', () => { expect(skill.routerPrefixSha256).toBe( ROUTER_SKILL_DESCRIPTION_PREFIX_SHA256 ); + expect(skill.provenance).toEqual( + expect.objectContaining({ + source: 'firecrawl/cli', + sourceUrl: 'https://github.com/firecrawl/cli.git', + skillPath: expect.stringContaining(skill.skillName), + }) + ); expect( readFileSync(path.join(skill.path, 'SKILL.md'), 'utf8') ).toContain(ROUTER_SKILL_DESCRIPTION_PREFIX); @@ -110,6 +172,7 @@ describe('full project router state', () => { sourceSha256: skill.sourceSha256, routedSha256: skill.routedSha256, routerPrefixSha256: ROUTER_SKILL_DESCRIPTION_PREFIX_SHA256, + provenance: skill.provenance, }); } expect( @@ -126,9 +189,12 @@ describe('full project router state', () => { path.join(home, '.agents', 'skills', 'firecrawl-alpha', 'reference.txt'), 'updated\n' ); + refreshLockedSkillHash('firecrawl-alpha'); rmSync(path.join(home, '.agents', 'skills', 'firecrawl-beta'), { recursive: true, }); + delete lockSkills['firecrawl-beta']; + writeSkillLock(); const userSkill = path.join(project, '.agents', 'skills', 'firecrawl-user'); mkdirSync(userSkill); writeFileSync(path.join(userSkill, 'SKILL.md'), 'user owned\n'); @@ -146,6 +212,57 @@ describe('full project router state', () => { ); }); + it('sources skills only from lock entries with allowed repo provenance', () => { + canonicalSkill('unprefixed-approved', 'Approved.', 'firecrawl/skills'); + canonicalSkill( + 'firecrawl-untrusted', + 'Untrusted.', + 'someone/else', + 'https://github.com/someone/else.git' + ); + canonicalSkill( + 'firecrawl-mismatched-url', + 'Mismatch.', + 'firecrawl/cli', + 'https://github.com/someone/else.git' + ); + + const receipt = install(); + + expect(receipt.skills.installed.map((item) => item.skillName)).toContain( + 'unprefixed-approved' + ); + expect( + receipt.skills.installed.map((item) => item.skillName) + ).not.toContain('firecrawl-untrusted'); + expect( + receipt.skills.installed.map((item) => item.skillName) + ).not.toContain('firecrawl-mismatched-url'); + expect( + existsSync(path.join(project, '.agents', 'skills', 'firecrawl-untrusted')) + ).toBe(false); + }); + + it('rejects a missing or malformed provenance lock before card mutation', () => { + rmSync(path.join(home, '.agents', '.skill-lock.json')); + expect(() => install()).toThrow('provenance lock is missing'); + expect(existsSync(path.join(project, 'AGENTS.md'))).toBe(false); + + writeFileSync(path.join(home, '.agents', '.skill-lock.json'), '{bad'); + expect(() => install()).toThrow('provenance lock is malformed'); + expect(existsSync(path.join(project, 'AGENTS.md'))).toBe(false); + }); + + it('rejects canonical skill content that no longer matches its lock hash', () => { + writeFileSync( + path.join(home, '.agents', 'skills', 'firecrawl-alpha', 'reference.txt'), + 'tampered\n' + ); + + expect(() => install()).toThrow('does not match its provenance hash'); + expect(existsSync(path.join(project, 'AGENTS.md'))).toBe(false); + }); + it('rejects user-owned collisions before writing a card', () => { const collision = path.join( project, @@ -187,6 +304,45 @@ describe('full project router state', () => { ); }); + it('rolls back card and skill mutations when a managed replacement fails', () => { + const first = install(); + const managed = first.skills.installed.find( + (item) => item.skillName === 'firecrawl-alpha' + )!; + const originalSkill = readFileSync( + path.join(managed.path, 'reference.txt'), + 'utf8' + ); + writeFileSync(path.join(project, 'AGENTS.md'), 'user context\n'); + writeFileSync( + path.join(home, '.agents', 'skills', 'firecrawl-alpha', 'reference.txt'), + 'updated\n' + ); + refreshLockedSkillHash('firecrawl-alpha'); + + const originalRename = fs.renameSync.bind(fs); + const rename = vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (String(from).endsWith('.tmp') && String(to) === managed.path) { + throw new Error('simulated managed replacement failure'); + } + return originalRename(from, to); + }); + + expect(() => install()).toThrow('simulated managed replacement failure'); + rename.mockRestore(); + expect(readFileSync(path.join(project, 'AGENTS.md'), 'utf8')).toBe( + 'user context\n' + ); + expect(readFileSync(path.join(managed.path, 'reference.txt'), 'utf8')).toBe( + originalSkill + ); + expect( + readdirSync(path.dirname(managed.path)).filter( + (name) => name.endsWith('.tmp') || name.endsWith('.backup') + ) + ).toEqual([]); + }); + it('rejects symlinks inside canonical source skills', () => { symlinkSync( path.join(home, '.agents', 'skills', 'firecrawl-alpha', 'reference.txt'), @@ -196,26 +352,66 @@ describe('full project router state', () => { expect(existsSync(path.join(project, 'AGENTS.md'))).toBe(false); }); - it('never refreshes or removes a modified managed skill', () => { + it('never refreshes or deletes a modified managed skill while still opting out', () => { const receipt = install(); const managed = receipt.skills.installed[0].path; writeFileSync(path.join(managed, 'user-note.txt'), 'keep me\n'); expect(() => install()).toThrow('modified router skill'); - expect(() => removeFullProjectRouterState('codex', project)).toThrow( - 'modified router skill' - ); + const removed = removeFullProjectRouterState('codex', project); + expect(removed.status).toBe('removed-with-preserved-content'); + expect(removed.operationComplete).toBe(false); + expect(removed.artifactsConfigured).toBe(false); + expect(removed.skills.preserved).toEqual([ + expect.objectContaining({ + path: managed, + skillName: path.basename(managed), + reason: expect.stringContaining('modified router skill'), + }), + ]); expect(readFileSync(path.join(managed, 'user-note.txt'), 'utf8')).toBe( 'keep me\n' ); expect(existsSync(path.join(project, 'AGENTS.md'))).toBe(true); + expect(readFileSync(path.join(project, 'AGENTS.md'), 'utf8')).toBe(''); + expect(readFileSync(removed.preference.path, 'utf8')).toContain( + '"enabled": false' + ); + }); + + it('preserves a symlinked managed skill while still removing the card', () => { + const receipt = install(); + const managed = receipt.skills.installed[0].path; + const referent = path.join(home, 'preserved-router-skill'); + rmSync(managed, { recursive: true }); + mkdirSync(referent); + writeFileSync(path.join(referent, 'SKILL.md'), 'user content\n'); + writeFileSync( + path.join(referent, '.firecrawl-router-skill.json'), + JSON.stringify(receipt.skills.installed[0]) + ); + symlinkSync(referent, managed); + + const removed = removeFullProjectRouterState('codex', project); + + expect(removed.status).toBe('removed-with-preserved-content'); + expect(removed.skills.preserved).toEqual([ + expect.objectContaining({ + path: managed, + reason: expect.stringContaining('symlink'), + }), + ]); + expect(readFileSync(path.join(referent, 'SKILL.md'), 'utf8')).toBe( + 'user content\n' + ); + expect(readFileSync(path.join(project, 'AGENTS.md'), 'utf8')).toBe(''); }); it('persists removal as an opt-out until explicitly re-enabled', () => { install(); const removed = removeFullProjectRouterState('codex', project); - expect(removed.complete).toBe(true); + expect(removed.operationComplete).toBe(true); expect(removed.skills.removed).toHaveLength(2); expect(removed.preference).toMatchObject({ enabled: false, changed: true }); expect(readFileSync(removed.preference.path, 'utf8')).toContain( @@ -226,7 +422,8 @@ describe('full project router state', () => { const disabled = install(); expect(disabled.status).toBe('disabled'); - expect(disabled.complete).toBe(false); + expect(disabled.operationComplete).toBe(true); + expect(disabled.artifactsConfigured).toBe(false); expect(existsSync(path.join(project, '.agents', 'skills'))).toBe(true); expect(readdirSync(path.join(project, '.agents', 'skills'))).toEqual([]); @@ -236,6 +433,58 @@ describe('full project router state', () => { expect(existsSync(enabled.preference.path)).toBe(false); }); + it('persists opt-out while preserving malformed router-card content', () => { + const malformed = + 'user context\n\nmissing end\n'; + writeFileSync(path.join(project, 'AGENTS.md'), malformed); + + const removed = removeFullProjectRouterState('codex', project); + + expect(removed.status).toBe('removed-with-preserved-content'); + expect(removed.operationComplete).toBe(false); + expect(removed.preservedCard).toEqual({ + path: path.join(project, 'AGENTS.md'), + reason: expect.stringContaining('malformed or duplicate'), + }); + expect(readFileSync(path.join(project, 'AGENTS.md'), 'utf8')).toBe( + malformed + ); + expect(readFileSync(removed.preference.path, 'utf8')).toContain( + '"enabled": false' + ); + }); + + it('preflights a malformed preference before removing card or skill state', () => { + const installed = install(); + const cardPath = path.join(project, 'AGENTS.md'); + mkdirSync(path.dirname(installed.preference.path), { recursive: true }); + writeFileSync(installed.preference.path, '{malformed'); + + expect(() => removeFullProjectRouterState('codex', project)).toThrow( + 'preference is malformed' + ); + + expect(readFileSync(cardPath, 'utf8')).toContain('Firecrawl web routing'); + for (const skill of installed.skills.installed) { + expect(existsSync(skill.path)).toBe(true); + } + expect(readFileSync(installed.preference.path, 'utf8')).toBe('{malformed'); + }); + + it('omits unknown preference state from a skipped receipt', () => { + const receipt = skippedFullProjectRouterState( + 'codex', + project, + 'malformed preference' + ); + + expect(receipt.preference).toEqual({ + path: path.join(project, '.firecrawl', 'router-card.json'), + changed: false, + }); + expect(receipt.preference).not.toHaveProperty('enabled'); + }); + it('fails closed unless all accepted-state prerequisites are true', () => { expect(() => installFullProjectRouterState({ diff --git a/src/__tests__/utils/router-card.test.ts b/src/__tests__/utils/router-card.test.ts index 1ec000b02..d0068e18c 100644 --- a/src/__tests__/utils/router-card.test.ts +++ b/src/__tests__/utils/router-card.test.ts @@ -131,6 +131,15 @@ describe('router card', () => { expect(resolveRouterCardProject(undefined, nested)).toBe(root); }); + it('requires detected Git ownership for implicit delivery but allows explicit projects', () => { + const root = project(); + + expect(() => resolveRouterCardProject(undefined, root)).toThrow( + 'requires a Git project' + ); + expect(resolveRouterCardProject(root, os.tmpdir())).toBe(root); + }); + it('adds the exact prefix without replacing the original description', () => { const source = '---\nname: firecrawl-test\ndescription: Original.\n---\nBody\n'; diff --git a/src/commands/launch.ts b/src/commands/launch.ts index ee1f11e51..a303f1915 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -10,6 +10,17 @@ import { installSkillsForAgent, } from './setup'; import { ALL_SKILL_REPOS } from './skills-install'; +import { getApiKey } from '../utils/config'; +import { + installFullProjectRouterState, + removeFullProjectRouterState, + skippedFullProjectRouterState, + type FullProjectRouterStateReceipt, +} from '../utils/project-router-state'; +import { + resolveRouterCardProject, + type RouterCardAgent, +} from '../utils/router-card'; export interface LaunchOptions { config?: boolean; @@ -19,6 +30,9 @@ export interface LaunchOptions { yes?: boolean; skipMcp?: boolean; skipSkills?: boolean; + routerCard?: boolean; + removeRouterCard?: boolean; + project?: string; } interface LaunchTarget { @@ -31,6 +45,7 @@ interface LaunchTarget { args?: string[]; supportsExtraArgs?: boolean; fallbackCommand?: () => { command: string; args: string[] } | null; + routerCardAgent?: RouterCardAgent; } type LaunchSetupMode = 'both' | 'mcp' | 'skills'; @@ -38,6 +53,7 @@ type LaunchSetupMode = 'both' | 'mcp' | 'skills'; const TARGETS: LaunchTarget[] = [ { aliases: ['claude', 'claude-code'], + routerCardAgent: 'claude', displayName: 'Claude Code', mcpAgent: 'claude-code', skillsAgent: 'claude-code', @@ -65,6 +81,7 @@ const TARGETS: LaunchTarget[] = [ }, { aliases: ['codex'], + routerCardAgent: 'codex', displayName: 'Codex', mcpAgent: 'codex', skillsAgent: 'codex', @@ -231,7 +248,7 @@ export async function handleLaunchCommand( targetName?: string, options: LaunchOptions = {}, extraArgs: string[] = [] -): Promise { +): Promise { if (!targetName && extraArgs.length > 0) { throw new Error( 'Extra launch arguments require an explicit launch target.' @@ -247,12 +264,54 @@ export async function handleLaunchCommand( const targetSupportsMcp = Boolean(target.mcpInstaller || target.mcpAgent); const targetSupportsSkills = Boolean(target.skillsAgent); + const suppliedProject = + options.project !== undefined + ? resolveRouterCardProject(options.project) + : undefined; + const launchProject = suppliedProject ?? path.resolve(process.cwd()); + let routerReceipt: FullProjectRouterStateReceipt | undefined; + const explicitlyEnableRouter = options.routerCard === true; + const hasRouterOption = + options.routerCard !== undefined || options.removeRouterCard; + if (explicitlyEnableRouter && options.removeRouterCard) { + throw new Error('Cannot combine --router-card with --remove-router-card.'); + } + if (explicitlyEnableRouter && (options.skipMcp || options.skipSkills)) { + throw new Error( + '--router-card requires MCP and skills; remove --skip-mcp and --skip-skills.' + ); + } + if (hasRouterOption && !target.routerCardAgent) { + throw new Error( + 'Project router state is currently supported for Claude and the Codex CLI.' + ); + } + if (explicitlyEnableRouter && !getApiKey()) { + throw new Error( + 'Router state requires an API key plus successful MCP and skills setup.' + ); + } + const explicitRouterProject = hasRouterOption + ? (suppliedProject ?? resolveRouterCardProject()) + : undefined; + if (options.removeRouterCard || options.routerCard === false) { + const receipt = removeFullProjectRouterState( + target.routerCardAgent!, + explicitRouterProject! + ); + routerReceipt = receipt; + console.log( + `Firecrawl project routing disabled at ${receipt.project}; removed ${receipt.skills.removed.length} managed skill(s)${receipt.card?.changed ? ' and the router card' : ''}${receipt.skills.preserved.length > 0 ? `; preserved ${receipt.skills.preserved.length} modified or unsafe skill path(s)` : ''}${receipt.preservedCard ? '; preserved malformed or unsafe router-card content' : ''}. Re-enable with \`firecrawl launch ${target.aliases[0]} --router-card --project ${receipt.project}\`.` + ); + if (options.removeRouterCard) return receipt; + } let installMcpForTarget = targetSupportsMcp && !options.skipMcp; let installSkillsForTarget = targetSupportsSkills && !options.skipSkills; if ( installMcpForTarget && installSkillsForTarget && + !explicitlyEnableRouter && !options.yes && process.stdin.isTTY ) { @@ -288,15 +347,68 @@ export async function handleLaunchCommand( ); } + const canInstallRouterState = Boolean( + target.routerCardAgent && + options.routerCard !== false && + installMcpForTarget && + installSkillsForTarget && + getApiKey() + ); + if (options.routerCard === true && !canInstallRouterState) { + throw new Error( + 'Router state requires an API key plus successful MCP and skills setup.' + ); + } + if (canInstallRouterState) { + let receipt: FullProjectRouterStateReceipt; + try { + const routerProject = + explicitRouterProject ?? resolveRouterCardProject(options.project); + receipt = installFullProjectRouterState({ + agent: target.routerCardAgent!, + project: routerProject, + authenticated: true, + mcpInstalled: true, + skillsInstalled: true, + forceEnable: explicitlyEnableRouter, + }); + } catch (error) { + if (explicitlyEnableRouter) throw error; + const warning = + error instanceof Error + ? error.message + : 'unknown router delivery error'; + receipt = skippedFullProjectRouterState( + target.routerCardAgent!, + options.project ?? process.cwd(), + warning + ); + console.warn( + `Firecrawl project routing skipped safely: ${warning} Ordinary ${target.displayName} setup will continue.` + ); + } + routerReceipt = receipt; + if (receipt.status === 'disabled') { + console.log( + `Firecrawl project routing remains disabled at ${receipt.project}. Re-enable with \`firecrawl launch ${target.aliases[0]} --router-card --project ${receipt.project}\`.` + ); + } else if (receipt.status !== 'skipped-safe') { + console.log( + `Firecrawl project routing ${receipt.status === 'current' ? 'is already current' : 'installed'} at ${receipt.project} (card sha256:${receipt.card!.sha256}; ${receipt.skills.sourceCount} routed skills).` + ); + } + } + if (options.config || options.install || options.setup) { console.log(`${target.displayName} is configured with Firecrawl MCP.`); - return; + return routerReceipt; } const launch = resolveLaunchCommand(target, extraArgs); const result = spawnSync(launch.command, launch.args, { stdio: 'inherit', env: process.env, + cwd: launchProject, }); if (result.error) { @@ -305,4 +417,5 @@ export async function handleLaunchCommand( if (typeof result.status === 'number' && result.status !== 0) { process.exit(result.status); } + return routerReceipt; } diff --git a/src/index.ts b/src/index.ts index 8949f0732..0b775e6e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2230,6 +2230,22 @@ program .option('--config', 'Alias for --install') .option('--skip-mcp', 'Launch without installing or updating Firecrawl MCP') .option('--skip-skills', 'Launch without installing Firecrawl skills') + .option( + '--router-card', + 'Enable or re-enable the tested project router state for Claude or Codex CLI' + ) + .option( + '--no-router-card', + 'Permanently disable and remove managed project router state' + ) + .option( + '--remove-router-card', + 'Permanently disable and remove managed project router state, then exit' + ) + .option( + '--project ', + 'Project directory for router state and the launched agent' + ) .option( '-g, --global', 'Install Firecrawl MCP globally for the selected agent', diff --git a/src/utils/project-router-state.ts b/src/utils/project-router-state.ts index f69b0f9b2..3f1652c86 100644 --- a/src/utils/project-router-state.ts +++ b/src/utils/project-router-state.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; +import { ALL_SKILL_REPOS } from '../commands/skills-install'; import { addRouterGuidanceToSkillDescription, assertNotSymlink, @@ -10,28 +11,46 @@ import { atomicWriteFile, installRouterCard, removeRouterCard, + routerCardPath, + resolveRouterCardContext, ROUTER_SKILL_DESCRIPTION_PREFIX_SHA256, type RouterCardAgent, type RouterCardResult, } from './router-card'; -const MANAGED_SKILL_VERSION = 1; +const MANAGED_SKILL_VERSION = 2; const MANAGED_SKILL_MARKER = '.firecrawl-router-skill.json'; const PREFERENCE_VERSION = 1; const PREFERENCE_PATH = path.join('.firecrawl', 'router-card.json'); const CANONICAL_SKILLS_DIR = path.join('.agents', 'skills'); +const SKILL_LOCK_PATH = path.join('.agents', '.skill-lock.json'); const PROJECT_SKILLS_DIRS: Record = { claude: path.join('.claude', 'skills'), codex: path.join('.agents', 'skills'), }; +export interface SkillSourceProvenance { + source: string; + sourceUrl: string; + skillPath: string; + skillFolderHash: string; +} + +interface SkillLockFile { + version: number; + skills: Record; +} + +const ALLOWED_SKILL_REPOS = new Set(ALL_SKILL_REPOS); + interface ManagedSkillMarker { version: number; skillName: string; sourceSha256: string; routedSha256: string; routerPrefixSha256: string; + provenance: SkillSourceProvenance; } export interface ManagedSkillReceipt extends ManagedSkillMarker { @@ -39,6 +58,16 @@ export interface ManagedSkillReceipt extends ManagedSkillMarker { status: 'installed' | 'refreshed' | 'current' | 'pruned' | 'removed'; } +export interface PreservedSkillReceipt { + path: string; + skillName: string; + reason: string; + sourceSha256?: string; + routedSha256?: string; + routerPrefixSha256?: string; + provenance?: SkillSourceProvenance; +} + export interface ProjectSkillsReceipt { root: string; changed: boolean; @@ -48,21 +77,42 @@ export interface ProjectSkillsReceipt { current: ManagedSkillReceipt[]; pruned: ManagedSkillReceipt[]; removed: ManagedSkillReceipt[]; + preserved: PreservedSkillReceipt[]; } export interface RouterPreferenceReceipt { path: string; - enabled: boolean; + enabled?: boolean; changed: boolean; } +interface ResolvedRouterPreferenceReceipt extends RouterPreferenceReceipt { + enabled: boolean; +} + export interface FullProjectRouterStateReceipt { operation: 'install' | 'remove'; - status: 'installed' | 'current' | 'disabled' | 'removed'; + status: + | 'installed' + | 'current' + | 'disabled' + | 'skipped-safe' + | 'removed' + | 'removed-with-preserved-content'; agent: RouterCardAgent; project: string; - complete: boolean; + operationComplete: boolean; + artifactsConfigured: boolean; + nativeDiscovery: { + status: 'pending' | 'unverified'; + verified: false; + }; + warning?: string; card?: RouterCardResult; + preservedCard?: { + path: string; + reason: string; + }; skills: ProjectSkillsReceipt; preference: RouterPreferenceReceipt; } @@ -89,6 +139,7 @@ function emptySkillsReceipt(root: string): ProjectSkillsReceipt { current: [], pruned: [], removed: [], + preserved: [], }; } @@ -100,7 +151,7 @@ function projectSkillsRoot(agent: RouterCardAgent, project: string): string { return path.join(project, PROJECT_SKILLS_DIRS[agent]); } -function readPreference(project: string): RouterPreferenceReceipt { +function readPreference(project: string): ResolvedRouterPreferenceReceipt { const destination = preferencePath(project); assertSafeProjectPath(project, destination); if (!fs.existsSync(destination)) { @@ -128,8 +179,10 @@ function readPreference(project: string): RouterPreferenceReceipt { }; } -function disablePreference(project: string): RouterPreferenceReceipt { - const current = readPreference(project); +function disablePreference( + project: string, + current = readPreference(project) +): ResolvedRouterPreferenceReceipt { if (!current.enabled) return current; atomicWriteFile( current.path, @@ -139,7 +192,7 @@ function disablePreference(project: string): RouterPreferenceReceipt { return { ...current, enabled: false, changed: true }; } -function enablePreference(project: string): RouterPreferenceReceipt { +function enablePreference(project: string): ResolvedRouterPreferenceReceipt { const current = readPreference(project); if (current.enabled) return current; fs.rmSync(current.path); @@ -223,7 +276,8 @@ function parseMarker( candidate.skillName !== skillName || typeof candidate.sourceSha256 !== 'string' || typeof candidate.routedSha256 !== 'string' || - candidate.routerPrefixSha256 !== ROUTER_SKILL_DESCRIPTION_PREFIX_SHA256 + candidate.routerPrefixSha256 !== ROUTER_SKILL_DESCRIPTION_PREFIX_SHA256 || + !isAllowedSkillProvenance(candidate.provenance) ) { throw new Error(`Managed router skill marker is malformed: ${markerPath}`); } @@ -258,7 +312,8 @@ function inspectOwnedSkill( function stageRoutedSkill( source: string, parent: string, - skillName: string + skillName: string, + provenance: SkillSourceProvenance ): { stage: string; marker: ManagedSkillMarker } { const stage = path.join(parent, `.${skillName}.${randomUUID()}.tmp`); try { @@ -278,6 +333,7 @@ function stageRoutedSkill( sourceSha256, routedSha256, routerPrefixSha256: ROUTER_SKILL_DESCRIPTION_PREFIX_SHA256, + provenance, }; atomicWriteFile( path.join(stage, MANAGED_SKILL_MARKER), @@ -291,42 +347,153 @@ function stageRoutedSkill( } } -function replaceDirectoryAtomically(stage: string, destination: string): void { - if (!fs.existsSync(destination)) { - fs.renameSync(stage, destination); - return; - } +function expectedSourceUrl(source: string): string { + return `https://github.com/${source}.git`; +} - const backup = `${destination}.${randomUUID()}.backup`; - fs.renameSync(destination, backup); - try { - fs.renameSync(stage, destination); - fs.rmSync(backup, { recursive: true, force: true }); - } catch (error) { - if (!fs.existsSync(destination)) fs.renameSync(backup, destination); - throw error; +function isAllowedSkillProvenance( + provenance: unknown +): provenance is SkillSourceProvenance { + if (!provenance || typeof provenance !== 'object') return false; + const candidate = provenance as Partial; + return ( + typeof candidate.source === 'string' && + ALLOWED_SKILL_REPOS.has(candidate.source) && + candidate.sourceUrl === expectedSourceUrl(candidate.source) && + typeof candidate.skillPath === 'string' && + candidate.skillPath.length > 0 && + typeof candidate.skillFolderHash === 'string' && + /^[a-f0-9]{40}$/.test(candidate.skillFolderHash) + ); +} + +/** Must stay byte-compatible with skills-native's lock-file hash. */ +function skillLockDigest(root: string): string { + const hash = createHash('sha1'); + + function walk(current: string): void { + for (const entry of fs + .readdirSync(current, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name.startsWith('.')) continue; + const absolute = path.join(current, entry.name); + const stat = fs.lstatSync(absolute); + if (stat.isSymbolicLink()) { + throw new Error(`Router skill source contains a symlink: ${absolute}`); + } + if (stat.isDirectory()) { + walk(absolute); + } else if (stat.isFile()) { + hash.update(entry.name); + hash.update(fs.readFileSync(absolute)); + } else { + throw new Error(`Unsupported router skill source entry: ${absolute}`); + } + } } + + walk(root); + return hash.digest('hex'); } -function discoverCanonicalSkills(): Array<{ name: string; source: string }> { +function discoverCanonicalSkills(): Array<{ + name: string; + source: string; + provenance: SkillSourceProvenance; +}> { const canonical = path.join(os.homedir(), CANONICAL_SKILLS_DIR); + const lockPath = path.join(os.homedir(), SKILL_LOCK_PATH); assertNotSymlink(canonical, canonical); if (!fs.existsSync(canonical)) { throw new Error(`Firecrawl canonical skills are missing: ${canonical}`); } - return fs - .readdirSync(canonical, { withFileTypes: true }) - .filter((entry) => entry.name.startsWith('firecrawl-')) - .map((entry) => { - const source = path.join(canonical, entry.name); - if (!entry.isDirectory() || entry.isSymbolicLink()) { - throw new Error( - `Router skill source must be a real directory: ${source}` - ); + assertNotSymlink(lockPath, lockPath); + if (!fs.existsSync(lockPath)) { + throw new Error(`Firecrawl skill provenance lock is missing: ${lockPath}`); + } + + let lock: SkillLockFile; + try { + lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as SkillLockFile; + } catch { + throw new Error( + `Firecrawl skill provenance lock is malformed: ${lockPath}` + ); + } + if ( + !lock || + typeof lock !== 'object' || + lock.version !== 3 || + !lock.skills || + typeof lock.skills !== 'object' || + Array.isArray(lock.skills) + ) { + throw new Error( + `Firecrawl skill provenance lock is malformed: ${lockPath}` + ); + } + + const sources: Array<{ + name: string; + source: string; + provenance: SkillSourceProvenance; + }> = []; + for (const [name, provenance] of Object.entries(lock.skills)) { + if ( + path.basename(name) !== name || + name === '.' || + name === '..' || + !provenance || + typeof provenance !== 'object' + ) { + continue; + } + if (!isAllowedSkillProvenance(provenance)) { + const candidate = provenance as Partial; + const sourceRepo = String(candidate.source ?? ''); + if ( + !ALLOWED_SKILL_REPOS.has(sourceRepo) || + candidate.sourceUrl !== expectedSourceUrl(sourceRepo) + ) { + continue; } - return { name: entry.name, source }; - }) - .sort((a, b) => a.name.localeCompare(b.name)); + throw new Error(`Firecrawl skill provenance is malformed for ${name}.`); + } + const sourceRepo = provenance.source; + if ( + !ALLOWED_SKILL_REPOS.has(sourceRepo) || + provenance.sourceUrl !== expectedSourceUrl(sourceRepo) + ) { + continue; + } + const source = path.join(canonical, name); + if (!fs.existsSync(source)) { + throw new Error(`Locked Firecrawl skill is missing: ${source}`); + } + const stat = fs.lstatSync(source); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error( + `Router skill source must be a real directory: ${source}` + ); + } + const currentFolderHash = skillLockDigest(source); + if (currentFolderHash !== provenance.skillFolderHash) { + throw new Error( + `Locked Firecrawl skill content does not match its provenance hash: ${source}` + ); + } + sources.push({ + name, + source, + provenance: { + source: sourceRepo, + sourceUrl: provenance.sourceUrl, + skillPath: provenance.skillPath, + skillFolderHash: provenance.skillFolderHash, + }, + }); + } + return sources.sort((a, b) => a.name.localeCompare(b.name)); } export function installManagedProjectSkills( @@ -346,10 +513,10 @@ export function installManagedProjectSkills( const sourceNames = new Set(sources.map((source) => source.name)); const existingManaged = fs .readdirSync(root, { withFileTypes: true }) - .filter((entry) => entry.name.startsWith('firecrawl-')) - .filter((entry) => - fs.existsSync(path.join(root, entry.name, MANAGED_SKILL_MARKER)) - ) + .filter((entry) => { + if (!entry.isDirectory() || entry.isSymbolicLink()) return false; + return fs.existsSync(path.join(root, entry.name, MANAGED_SKILL_MARKER)); + }) .map((entry) => entry.name); const existingMarkers = new Map(); @@ -380,7 +547,12 @@ export function installManagedProjectSkills( for (const source of sources) { stagedSkills.push({ name: source.name, - ...stageRoutedSkill(source.source, root, source.name), + ...stageRoutedSkill( + source.source, + root, + source.name, + source.provenance + ), }); } } catch (error) { @@ -390,42 +562,89 @@ export function installManagedProjectSkills( throw error; } - for (const staged of stagedSkills) { - const destination = path.join(root, staged.name); - const existing = existingMarkers.get(staged.name); - if ( - existing && - existing.sourceSha256 === staged.marker.sourceSha256 && - existing.routedSha256 === staged.marker.routedSha256 - ) { - fs.rmSync(staged.stage, { recursive: true, force: true }); - receipt.current.push({ - ...existing, + const applied: Array<{ + destination: string; + backup?: string; + kind: 'installed' | 'refreshed' | 'pruned'; + }> = []; + try { + for (const staged of stagedSkills) { + const destination = path.join(root, staged.name); + const existing = existingMarkers.get(staged.name); + if ( + existing && + existing.sourceSha256 === staged.marker.sourceSha256 && + existing.routedSha256 === staged.marker.routedSha256 && + JSON.stringify(existing.provenance) === + JSON.stringify(staged.marker.provenance) + ) { + fs.rmSync(staged.stage, { recursive: true, force: true }); + receipt.current.push({ + ...existing, + path: destination, + status: 'current', + }); + continue; + } + + let backup: string | undefined; + if (existing) { + backup = `${destination}.${randomUUID()}.backup`; + fs.renameSync(destination, backup); + } + try { + fs.renameSync(staged.stage, destination); + } catch (error) { + if (backup && !fs.existsSync(destination)) { + fs.renameSync(backup, destination); + } + throw error; + } + const status = existing ? 'refreshed' : 'installed'; + applied.push({ destination, backup, kind: status }); + receipt[status].push({ + ...staged.marker, path: destination, - status: 'current', + status, }); - continue; } - replaceDirectoryAtomically(staged.stage, destination); - const status = existing ? 'refreshed' : 'installed'; - receipt[status].push({ - ...staged.marker, - path: destination, - status, - }); - } + for (const name of existingManaged) { + if (sourceNames.has(name)) continue; + const destination = path.join(root, name); + const marker = existingMarkers.get(name)!; + const backup = `${destination}.${randomUUID()}.backup`; + fs.renameSync(destination, backup); + applied.push({ destination, backup, kind: 'pruned' }); + receipt.pruned.push({ + ...marker, + path: destination, + status: 'pruned', + }); + } - for (const name of existingManaged) { - if (sourceNames.has(name)) continue; - const destination = path.join(root, name); - const marker = existingMarkers.get(name)!; - fs.rmSync(destination, { recursive: true }); - receipt.pruned.push({ - ...marker, - path: destination, - status: 'pruned', - }); + for (const mutation of applied) { + if (mutation.backup) { + try { + fs.rmSync(mutation.backup, { recursive: true, force: true }); + } catch { + // The new state is committed; a stale hidden backup is safer than rollback. + } + } + } + } catch (error) { + for (const mutation of [...applied].reverse()) { + if (mutation.kind !== 'pruned') { + fs.rmSync(mutation.destination, { recursive: true, force: true }); + } + if (mutation.backup && fs.existsSync(mutation.backup)) { + fs.renameSync(mutation.backup, mutation.destination); + } + } + for (const staged of stagedSkills) { + fs.rmSync(staged.stage, { recursive: true, force: true }); + } + throw error; } receipt.changed = @@ -441,20 +660,49 @@ export function removeManagedProjectSkills( ): ProjectSkillsReceipt { const project = path.resolve(projectPath); const root = projectSkillsRoot(agent, project); - assertSafeProjectPath(project, root); const receipt = emptySkillsReceipt(root); + try { + assertSafeProjectPath(project, root); + } catch (error) { + if (error instanceof Error && error.message.includes('symlink')) { + receipt.preserved.push({ + path: root, + skillName: '*', + reason: error.message, + }); + return receipt; + } + throw error; + } if (!fs.existsSync(root)) return receipt; const owned: Array<{ path: string; marker: ManagedSkillMarker }> = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - if (!entry.name.startsWith('firecrawl-')) continue; const skillPath = path.join(root, entry.name); const markerPath = path.join(skillPath, MANAGED_SKILL_MARKER); if (!fs.existsSync(markerPath)) continue; - owned.push({ - path: skillPath, - marker: inspectOwnedSkill(skillPath, entry.name), - }); + try { + owned.push({ + path: skillPath, + marker: inspectOwnedSkill(skillPath, entry.name), + }); + } catch (error) { + let marker: Partial = {}; + try { + marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + } catch { + // The preservation receipt still records the path and reason. + } + receipt.preserved.push({ + path: skillPath, + skillName: entry.name, + reason: error instanceof Error ? error.message : 'unsafe managed skill', + sourceSha256: marker.sourceSha256, + routedSha256: marker.routedSha256, + routerPrefixSha256: marker.routerPrefixSha256, + provenance: marker.provenance, + }); + } } for (const item of owned) { @@ -481,7 +729,9 @@ export function installFullProjectRouterState( status: 'disabled', agent: options.agent, project, - complete: false, + operationComplete: true, + artifactsConfigured: false, + nativeDiscovery: { status: 'unverified', verified: false }, skills: emptySkillsReceipt(root), preference, }; @@ -497,18 +747,45 @@ export function installFullProjectRouterState( } assertRouterCardStateSafe(options.agent, project); - const skills = installManagedProjectSkills(options.agent, project); - const card = installRouterCard(options.agent, project); const enabledPreference = options.forceEnable ? enablePreference(project) : preference; + const cardPath = routerCardPath( + project, + resolveRouterCardContext(options.agent) + ); + const cardExisted = fs.existsSync(cardPath); + const cardBefore = cardExisted ? fs.readFileSync(cardPath, 'utf8') : ''; + const cardMode = cardExisted ? fs.statSync(cardPath).mode & 0o777 : 0o644; + let card: RouterCardResult; + let skills: ProjectSkillsReceipt; + try { + card = installRouterCard(options.agent, project); + skills = installManagedProjectSkills(options.agent, project); + } catch (error) { + if (cardExisted) { + atomicWriteFile(cardPath, cardBefore, cardMode); + } else { + fs.rmSync(cardPath, { force: true }); + } + if (options.forceEnable && enabledPreference.changed) { + atomicWriteFile( + preference.path, + `${JSON.stringify({ version: PREFERENCE_VERSION, enabled: false }, null, 2)}\n`, + 0o644 + ); + } + throw error; + } const changed = skills.changed || card.changed || enabledPreference.changed; return { operation: 'install', status: changed ? 'installed' : 'current', agent: options.agent, project, - complete: true, + operationComplete: true, + artifactsConfigured: true, + nativeDiscovery: { status: 'pending', verified: false }, card, skills, preference: enabledPreference, @@ -520,18 +797,55 @@ export function removeFullProjectRouterState( projectPath: string ): FullProjectRouterStateReceipt { const project = path.resolve(projectPath); - assertRouterCardStateSafe(agent, project); + const currentPreference = readPreference(project); const skills = removeManagedProjectSkills(agent, project); - const card = removeRouterCard(agent, project); - const preference = disablePreference(project); + let card: RouterCardResult | undefined; + let preservedCard: FullProjectRouterStateReceipt['preservedCard']; + try { + card = removeRouterCard(agent, project); + } catch (error) { + preservedCard = { + path: routerCardPath(project, resolveRouterCardContext(agent)), + reason: + error instanceof Error ? error.message : 'unsafe managed router card', + }; + } + const preference = disablePreference(project, currentPreference); + const preserved = skills.preserved.length > 0 || Boolean(preservedCard); return { operation: 'remove', - status: 'removed', + status: preserved ? 'removed-with-preserved-content' : 'removed', agent, project, - complete: true, + operationComplete: !preserved, + artifactsConfigured: false, + nativeDiscovery: { status: 'unverified', verified: false }, card, + preservedCard, skills, preference, }; } + +export function skippedFullProjectRouterState( + agent: RouterCardAgent, + projectPath: string, + warning: string +): FullProjectRouterStateReceipt { + const project = path.resolve(projectPath); + return { + operation: 'install', + status: 'skipped-safe', + agent, + project, + operationComplete: false, + artifactsConfigured: false, + nativeDiscovery: { status: 'unverified', verified: false }, + warning, + skills: emptySkillsReceipt(projectSkillsRoot(agent, project)), + preference: { + path: preferencePath(project), + changed: false, + }, + }; +} diff --git a/src/utils/router-card.ts b/src/utils/router-card.ts index ddfc5e6c9..25330c960 100644 --- a/src/utils/router-card.ts +++ b/src/utils/router-card.ts @@ -108,9 +108,13 @@ export function resolveRouterCardProject( throw new Error(`Router card project is not a directory: ${requested}`); } - const project = explicitProject - ? requested - : (containingGitRoot(requested) ?? requested); + const gitRoot = explicitProject ? null : containingGitRoot(requested); + if (!explicitProject && !gitRoot) { + throw new Error( + 'Implicit router-card delivery requires a Git project. Pass --project to configure an explicit non-Git project.' + ); + } + const project = explicitProject ? requested : gitRoot!; if (isFilesystemRoot(project) || isHomeDirectory(project)) { throw new Error( 'Refusing to write a router card outside a project. Pass --project from a project directory.'