Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/tricky-spies-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'curl.md': patch
---

Added agent detection
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pnpm-lock.yaml linguist-generated
cli/src/cf-env.d.ts linguist-generated
db/schemas.gen.ts linguist-generated
db/types.gen.ts linguist-generated
src/routeTree.gen.ts linguist-generated
src/worker-configuration.d.ts linguist-generated
Expand Down
165 changes: 164 additions & 1 deletion cli/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { HttpResponse, http } from 'msw'
import { expect, test } from 'vitest'
import { afterEach, expect, test, vi } from 'vitest'
import { server } from '../test/server.ts'
import { createClient, defaultBaseUrl } from './client.ts'

afterEach(() => {
vi.unstubAllEnvs()
})

test('createClient.fetch moves target fragment into anchor query', async () => {
let requestUrl: URL | undefined
server.use(
Expand Down Expand Up @@ -56,3 +60,162 @@ test('createClient.fetch leaves hash-free target urls unchanged', async () => {
expect(requestUrl?.pathname).toBe('/api/example.com/foo%3Ftab%3Dapi')
expect(requestUrl?.searchParams.has('anchor')).toBe(false)
})

test('createClient adds explicit ai agent header', async () => {
let aiAgent: string | null | undefined
server.use(
http.get('*', ({ request }) => {
aiAgent = request.headers.get('x-ai-agent')
return HttpResponse.json({ version: 'x.y.z', published_at: null })
}),
)

const client = createClient(defaultBaseUrl, { aiAgent: 'gemini' })
const res = await client.api.cli.latest.$get({ query: {} })

expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ published_at: null, version: 'x.y.z' })
expect(aiAgent).toBe('gemini')
})

test('createClient aiAgent overrides std-env fallback detection', async () => {
let aiAgent: string | null | undefined
vi.stubEnv('AI_AGENT', 'codex')
server.use(
http.get('*', ({ request }) => {
aiAgent = request.headers.get('x-ai-agent')
return HttpResponse.json({ version: 'x.y.z', published_at: null })
}),
)

const client = createClient(defaultBaseUrl, { aiAgent: 'gemini' })
const res = await client.api.cli.latest.$get({ query: {} })

expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ published_at: null, version: 'x.y.z' })
expect(aiAgent).toBe('gemini')
})

test('createClient omits unsupported explicit aiAgent values without std-env fallback', async () => {
const aiAgents: Array<string | null> = []
vi.stubEnv('AI_AGENT', 'codex')
server.use(
http.get('*', ({ request }) => {
aiAgents.push(request.headers.get('x-ai-agent'))
return HttpResponse.json({ version: 'x.y.z', published_at: null })
}),
)

for (const aiAgent of ['', 'invalid']) {
const client = createClient(defaultBaseUrl, { aiAgent: aiAgent as never })
const res = await client.api.cli.latest.$get({ query: {} })

expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ published_at: null, version: 'x.y.z' })
}

expect(aiAgents).toEqual([null, null])
})

test('createClient falls back to std-env ai agent detection', async () => {
let aiAgent: string | null | undefined
vi.stubEnv('AI_AGENT', 'codex')
server.use(
http.get('*', ({ request }) => {
aiAgent = request.headers.get('x-ai-agent')
return HttpResponse.json({ version: 'x.y.z', published_at: null })
}),
)

const client = createClient(defaultBaseUrl)
const res = await client.api.cli.latest.$get({ query: {} })

expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ published_at: null, version: 'x.y.z' })
expect(aiAgent).toBe('codex')
})

test('createClient omits ai agent header when std-env detects an unsupported agent', async () => {
let aiAgent: string | null | undefined
vi.stubEnv('AI_AGENT', 'devin')
server.use(
http.get('*', ({ request }) => {
aiAgent = request.headers.get('x-ai-agent')
return HttpResponse.json({ version: 'x.y.z', published_at: null })
}),
)

const client = createClient(defaultBaseUrl)
const res = await client.api.cli.latest.$get({ query: {} })

expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ published_at: null, version: 'x.y.z' })
expect(aiAgent).toBeNull()
})

test('createClient.fetch keeps ai agent header when adding authorization', async () => {
let aiAgent: string | null | undefined
let authorization: string | null | undefined
server.use(
http.get('*', ({ request }) => {
aiAgent = request.headers.get('x-ai-agent')
authorization = request.headers.get('authorization')
return HttpResponse.json({ content: '# Example' })
}),
)

const client = createClient(defaultBaseUrl, { aiAgent: 'gemini' })
const res = await client.fetch('example.com', { token: 'curlmd_test' })

expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ content: '# Example' })
expect(aiAgent).toBe('gemini')
expect(authorization).toBe('Bearer curlmd_test')
})

test('createClient omits ai agent header in browser-like environment without process', async () => {
let aiAgent: string | null | undefined
const originalProcess = globalThis.process
// @ts-expect-error -- simulate browser environment
globalThis.process = undefined
server.use(
http.get('*', ({ request }) => {
aiAgent = request.headers.get('x-ai-agent')
return HttpResponse.json({ version: 'x.y.z', published_at: null })
}),
)

try {
const client = createClient(defaultBaseUrl)
const res = await client.api.cli.latest.$get({ query: {} })

expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ published_at: null, version: 'x.y.z' })
expect(aiAgent).toBeNull()
} finally {
globalThis.process = originalProcess
}
})

test('createClient keeps ai agent header when route requests add headers', async () => {
let aiAgent: string | null | undefined
let organizationId: string | null | undefined
server.use(
http.get('*', ({ request }) => {
aiAgent = request.headers.get('x-ai-agent')
organizationId = request.headers.get('x-organization-id')
return HttpResponse.json({ version: 'x.y.z', published_at: null })
}),
)

const client = createClient(defaultBaseUrl, { aiAgent: 'gemini' })
const res = await client.api.cli.latest.$get(
{ query: {} },
{ headers: { 'x-organization-id': 'org_123' } },
)

expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ published_at: null, version: 'x.y.z' })
expect(aiAgent).toBe('gemini')
expect(organizationId).toBe('org_123')
})
117 changes: 94 additions & 23 deletions cli/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,29 @@ export const defaultBaseUrl = 'https://curl.md'
* const res = await client.fetch('example.com')
* ```
*/
export function createClient(url: string = defaultBaseUrl, options?: ClientRequestOptions): Client {
const client = hc<typeof api>(url, options)
export function createClient(
url: string = defaultBaseUrl,
options?: ClientRequestOptions & {
aiAgent?: AiAgent | undefined
},
): Client {
const aiAgent = (() => {
if (options && Object.prototype.hasOwnProperty.call(options, 'aiAgent'))
return normalizeAiAgent(options.aiAgent)
return normalizeAiAgent(detectAgent().name)
})()

const client = hc<typeof api>(url, {
...options,
headers: getHeaders(options?.headers, undefined, { aiAgent }),
})

return new Proxy(client, {
get(target, prop, receiver) {
if (prop === 'fetch') {
return (targetUrl: string, fetchOptions?: FetchOptions | undefined) => {
const normalizedTargetURL = normalizeTargetURL(targetUrl)
const { options, token, ...queryOptions } = fetchOptions ?? {}
const { options: requestOptions, token, ...queryOptions } = fetchOptions ?? {}
const query = {
anchor: normalizedTargetURL.anchor,
...queryOptions,
Expand All @@ -35,12 +49,20 @@ export function createClient(url: string = defaultBaseUrl, options?: ClientReque
anchor?: string | undefined
}

const clientRequestOptions = (() => {
if (!requestOptions && !token) return undefined
return {
...requestOptions,
headers: getHeaders(options?.headers, requestOptions?.headers, { aiAgent, token }),
}
})()

return target.api[':url{.+}'].$get(
{
param: { url: normalizedTargetURL.url },
query,
},
token ? withAuthorizationHeader(options, token) : options,
clientRequestOptions,
)
}
}
Expand Down Expand Up @@ -69,28 +91,77 @@ type FetchOptions = Partial<Omit<FetchQuery, 'fresh' | 'keywords'>> & {
token?: string | undefined
}

function withAuthorizationHeader(
options: NonNullable<Parameters<Fetch>[1]> | undefined,
token: string,
): NonNullable<Parameters<Fetch>[1]> {
const headers = options?.headers
return {
...options,
headers:
typeof headers === 'function'
? async () => withTokenHeader(await headers(), token)
: withTokenHeader(headers, token),
}
function getHeaders(
baseHeaders: ClientRequestOptions['headers'],
requestHeaders: ClientRequestOptions['headers'],
opts: {
aiAgent?: AiAgent | undefined
token?: string | undefined
},
): ClientRequestOptions['headers'] {
if (typeof baseHeaders === 'function' || typeof requestHeaders === 'function')
return async () => {
const nextBaseHeaders = typeof baseHeaders === 'function' ? await baseHeaders() : baseHeaders
const nextRequestHeaders =
typeof requestHeaders === 'function' ? await requestHeaders() : requestHeaders
return mergeHeaders(nextBaseHeaders, nextRequestHeaders, opts) || {}
}
return mergeHeaders(baseHeaders, requestHeaders, opts)
}

function mergeHeaders(
baseHeaders: Record<string, string> | undefined,
requestHeaders: Record<string, string> | undefined,
opts: {
aiAgent?: AiAgent | undefined
token?: string | undefined
},
) {
if (!baseHeaders && !requestHeaders && !opts.aiAgent && !opts.token) return undefined
const nextHeaders = new Headers(baseHeaders)
if (requestHeaders)
for (const [name, value] of Object.entries(requestHeaders)) nextHeaders.set(name, value)
nextHeaders.delete('x-ai-agent')
if (opts.aiAgent) nextHeaders.set('x-ai-agent', opts.aiAgent)
if (opts.token) nextHeaders.set('Authorization', `Bearer ${opts.token}`)
return Object.fromEntries(nextHeaders.entries())
}

function withTokenHeader(headers: Record<string, string> | undefined, token: string) {
const nextHeaders = { ...headers }
for (const key of Object.keys(nextHeaders)) {
if (key.toLowerCase() !== 'authorization') continue
delete nextHeaders[key]
const aiAgents = ['amp', 'claude', 'codex', 'cursor', 'gemini', 'opencode', 'pi'] as const
type AiAgent = (typeof aiAgents)[number]

function normalizeAiAgent(value: unknown): AiAgent | undefined {
if (typeof value !== 'string') return undefined

const normalizedValue = value.toLowerCase()
if (!aiAgents.includes(normalizedValue as AiAgent)) return undefined
return normalizedValue as AiAgent
}

/**
* Vendored from std-env v4.1.0 — agent detection subset
* https://github.com/unjs/std-env
* MIT License
*/
function detectAgent(): { name?: string } {
const env = globalThis.process?.env || Object.create(null)
const aiAgent = env.AI_AGENT
if (aiAgent) return { name: aiAgent.toLowerCase() }

const rules: Array<[string, (string | (() => boolean))[]]> = [
['amp', [() => env.AGENT === 'amp']],
['claude', ['CLAUDECODE', 'CLAUDE_CODE']],
['codex', ['CODEX_SANDBOX', 'CODEX_THREAD_ID']],
['cursor', ['CURSOR_AGENT']],
['gemini', ['GEMINI_CLI']],
['opencode', ['OPENCODE']],
['pi', [() => /\.pi[\\/]agent/.test(env.PATH ?? '')]],
]
for (const [name, checks] of rules) {
for (const check of checks)
if (typeof check === 'string' ? env[check] : check()) return { name }
}
nextHeaders.Authorization = `Bearer ${token}`
return nextHeaders
return {}
}

function normalizeTargetURL(targetUrl: string) {
Expand Down
Loading
Loading