Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,28 @@ jobs:
- name: Publish to npm
run: npm publish --access public --provenance

- name: Verify npm release is live
env:
EXPECTED_VERSION: ${{ needs.release-please.outputs.version }}
run: |
set -euo pipefail

for attempt in $(seq 1 18); do
published_version="$(npm view @gitlawb/openclaude version 2>/dev/null || true)"
latest_tag="$(npm view @gitlawb/openclaude dist-tags.latest 2>/dev/null || true)"

if [ "$published_version" = "$EXPECTED_VERSION" ] && [ "$latest_tag" = "$EXPECTED_VERSION" ]; then
echo "Verified npm latest is $EXPECTED_VERSION"
exit 0
fi

echo "Attempt $attempt: version='$published_version' latest='$latest_tag' expected='$EXPECTED_VERSION'"
sleep 10
done

echo "npm registry did not resolve @gitlawb/openclaude latest to $EXPECTED_VERSION in time" >&2
exit 1

- name: Release summary
run: |
{
Expand Down
412 changes: 220 additions & 192 deletions bun.lock

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,15 @@
},
"dependencies": {
"@alcalzone/ansi-tokenize": "0.3.0",
"@anthropic-ai/bedrock-sdk": "0.26.4",
"@anthropic-ai/bedrock-sdk": "0.29.1",
"@anthropic-ai/foundry-sdk": "0.2.3",
"@anthropic-ai/sandbox-runtime": "0.0.46",
"@anthropic-ai/sdk": "0.81.0",
"@anthropic-ai/vertex-sdk": "0.14.4",
"@anthropic-ai/sdk": "0.94.0",
"@anthropic-ai/vertex-sdk": "0.16.0",
"@commander-js/extra-typings": "12.1.0",
"@growthbook/growthbook": "1.6.5",
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@mendable/firecrawl-js": "4.18.1",
"@modelcontextprotocol/sdk": "1.29.0",
"@opentelemetry/api": "1.9.1",
"@opentelemetry/api-logs": "0.214.0",
Expand All @@ -94,7 +93,7 @@
"@vscode/ripgrep": "^1.17.1",
"ajv": "8.18.0",
"auto-bind": "5.0.1",
"axios": "1.15.0",
"axios": "1.16.0",
"bidi-js": "1.0.3",
"chalk": "5.6.2",
"chokidar": "4.0.3",
Expand Down Expand Up @@ -175,6 +174,7 @@
"access": "public"
},
"overrides": {
"ip-address": "10.2.0",
Comment thread
jatmn marked this conversation as resolved.
"lodash-es": "4.18.1"
}
}
16 changes: 10 additions & 6 deletions src/tools/WebFetchTool/WebFetchTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,23 @@ import {
isPreapprovedUrl,
MAX_MARKDOWN_LENGTH,
} from './utils.js'
import { firecrawlScrape } from '../firecrawl/client.js'

function isFirecrawlEnabled(): boolean {
return Boolean(process.env.FIRECRAWL_API_KEY) || Boolean(process.env.FIRECRAWL_API_URL)
}

async function scrapeWithFirecrawl(url: string): Promise<{ markdown: string; bytes: number }> {
const { FirecrawlClient } = await import('@mendable/firecrawl-js')
const app = new FirecrawlClient({
async function scrapeWithFirecrawl(
url: string,
signal?: AbortSignal,
): Promise<{ markdown: string; bytes: number }> {
const result = await firecrawlScrape(url, {
apiKey: process.env.FIRECRAWL_API_KEY,
apiUrl: process.env.FIRECRAWL_API_URL,
formats: ['markdown'],
signal,
})
const result = await app.scrape(url, { formats: ['markdown'] })
const markdown = (result as { markdown?: string }).markdown ?? ''
const markdown = result.markdown ?? ''
return { markdown, bytes: Buffer.byteLength(markdown) }
}

Expand Down Expand Up @@ -227,7 +231,7 @@ ${DESCRIPTION}`
const start = Date.now()

if (isFirecrawlEnabled()) {
const { markdown, bytes } = await scrapeWithFirecrawl(url)
const { markdown, bytes } = await scrapeWithFirecrawl(url, abortController.signal)
const result = await applyPromptToMarkdown(
prompt,
markdown,
Expand Down
16 changes: 8 additions & 8 deletions src/tools/WebSearchTool/providers/firecrawl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SearchInput, SearchProvider } from './types.js'
import { applyDomainFilters, type ProviderOutput } from './types.js'
import { firecrawlSearch } from '../../firecrawl/client.js'

export const firecrawlProvider: SearchProvider = {
name: 'firecrawl',
Expand All @@ -11,23 +12,22 @@ export const firecrawlProvider: SearchProvider = {
async search(input: SearchInput, signal?: AbortSignal): Promise<ProviderOutput> {
const start = performance.now()
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
// TODO: @mendable/firecrawl-js SDK doesn't accept AbortSignal — can't cancel in-flight searches
const { FirecrawlClient } = await import('@mendable/firecrawl-js')
const app = new FirecrawlClient({
apiKey: process.env.FIRECRAWL_API_KEY,
apiUrl: process.env.FIRECRAWL_API_URL,
})

let query = input.query
if (input.blocked_domains?.length) {
const exclusions = input.blocked_domains.map(d => `-site:${d}`).join(' ')
query = `${query} ${exclusions}`
}

const data = await app.search(query, { limit: 15 })
const data = await firecrawlSearch(query, {
apiKey: process.env.FIRECRAWL_API_KEY,
apiUrl: process.env.FIRECRAWL_API_URL,
limit: 15,
signal,
})

const hits = applyDomainFilters(
(data.web ?? []).map((r: { url: string; title?: string; description?: string }) => ({
(data.web ?? []).map(r => ({
title: r.title ?? r.url,
url: r.url,
description: r.description,
Expand Down
133 changes: 133 additions & 0 deletions src/tools/firecrawl/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'

import { firecrawlScrape, firecrawlSearch } from './client.js'

const originalFetch = globalThis.fetch
const originalEnv = {
FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY,
FIRECRAWL_API_URL: process.env.FIRECRAWL_API_URL,
}

function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}

afterEach(() => {
globalThis.fetch = originalFetch
restoreEnv('FIRECRAWL_API_KEY', originalEnv.FIRECRAWL_API_KEY)
restoreEnv('FIRECRAWL_API_URL', originalEnv.FIRECRAWL_API_URL)
})

describe('firecrawl client', () => {
test('search posts to the v2 API with bearer auth', async () => {
process.env.FIRECRAWL_API_KEY = 'fc-test-key'
delete process.env.FIRECRAWL_API_URL

globalThis.fetch = mock(async (input, init) => {
expect(String(input)).toBe('https://api.firecrawl.dev/v2/search')
expect(init?.method).toBe('POST')
expect((init?.headers as Record<string, string>).Authorization).toBe('Bearer fc-test-key')

const body = JSON.parse(String(init?.body)) as Record<string, unknown>
expect(body).toMatchObject({
query: 'openclaude',
limit: 7,
origin: 'openclaude',
})

return new Response(
JSON.stringify({
success: true,
data: {
web: [{ url: 'https://example.com', title: 'Example', description: 'desc' }],
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof globalThis.fetch

await expect(firecrawlSearch('openclaude', { limit: 7 })).resolves.toEqual({
web: [{ url: 'https://example.com', title: 'Example', description: 'desc' }],
})
})

test('scrape allows self-hosted api urls without an api key', async () => {
delete process.env.FIRECRAWL_API_KEY
process.env.FIRECRAWL_API_URL = 'https://self-hosted.firecrawl.dev'

globalThis.fetch = mock(async (input, init) => {
expect(String(input)).toBe('https://self-hosted.firecrawl.dev/v2/scrape')
expect((init?.headers as Record<string, string>).Authorization).toBeUndefined()

const body = JSON.parse(String(init?.body)) as Record<string, unknown>
expect(body).toMatchObject({
url: 'https://example.com',
formats: ['markdown'],
origin: 'openclaude',
})

return new Response(
JSON.stringify({
success: true,
data: {
markdown: '# Example',
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof globalThis.fetch

await expect(firecrawlScrape('https://example.com')).resolves.toEqual({
markdown: '# Example',
})
})

test('cloud api requires an api key', async () => {
delete process.env.FIRECRAWL_API_KEY
delete process.env.FIRECRAWL_API_URL

await expect(firecrawlSearch('openclaude')).rejects.toThrow(
'Firecrawl API key is required for the cloud API.',
)
})

test('retries transient 502 responses before succeeding', async () => {
process.env.FIRECRAWL_API_KEY = 'fc-test-key'
delete process.env.FIRECRAWL_API_URL

let attempts = 0
globalThis.fetch = mock(async () => {
attempts += 1
if (attempts < 3) {
return new Response(
JSON.stringify({
success: false,
error: 'temporary upstream failure',
}),
{ status: 502, headers: { 'Content-Type': 'application/json' } },
)
}

return new Response(
JSON.stringify({
success: true,
data: {
web: [{ url: 'https://example.com/retried' }],
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof globalThis.fetch

await expect(
firecrawlSearch('openclaude', { maxRetries: 3, backoffFactorSeconds: 0 }),
).resolves.toEqual({
web: [{ url: 'https://example.com/retried' }],
})
expect(attempts).toBe(3)
})
})
Loading
Loading