Skip to content

src/lib/client.ts

151 lines · 4.7 KB · TypeScript
Raw
/** User-scoped Cursor API key (raw key from dashboard → API Keys). */
export const CURSOR_SECRET_NAME = 'cursorApiKey'
/** Bearer auth; Cursor also accepts Basic with key as username. */
const CURSOR_AUTH = 'Bearer {{secret:cursorApiKey}}'
export const CURSOR_BASE_URL = 'https://api.cursor.com'

/** Pull a human-readable detail from Cursor error bodies. */
export function cursorErrorDetail(body: unknown): string {
	if (typeof body === 'string') return body.trim().slice(0, 500)
	if (!body || typeof body !== 'object') return ''
	const root = body as Record<string, unknown>
	const nested =
		root.error && typeof root.error === 'object'
			? (root.error as Record<string, unknown>)
			: null
	const message =
		(typeof root.message === 'string' && root.message.trim()) ||
		(nested && typeof nested.message === 'string' && nested.message.trim()) ||
		''
	const code =
		(typeof root.code === 'string' && root.code.trim()) ||
		(nested && typeof nested.code === 'string' && nested.code.trim()) ||
		''
	if (message && code && !message.includes(code)) return `${code}: ${message}`
	return message || code
}

export class CursorApiError extends Error {
	status: number
	body: unknown

	constructor(status: number, body: unknown, message?: string) {
		const detail = cursorErrorDetail(body)
		const suffix = detail ? `: ${detail}` : ''
		const rateNote = status === 429 ? ' (rate limited — retry with backoff)' : ''
		const authNote =
			status === 401
				? ' (check that the "cursorApiKey" user secret is saved — cursor.com/dashboard → API Keys — with host approval for api.cursor.com)'
				: ''
		super(message || `Cursor API ${status}${suffix}${rateNote}${authNote}`)
		this.name = 'CursorApiError'
		this.status = status
		this.body = body
	}
}

export function clampInt(
	value: unknown,
	min: number,
	max: number,
	fallback: number,
): number {
	const number = Number(value)
	if (!Number.isFinite(number)) return fallback
	return Math.min(max, Math.max(min, Math.floor(number)))
}

export function trimString(value: unknown): string {
	return typeof value === 'string' ? value.trim() : ''
}

// NOTE: there is deliberately no secret_list pre-check here. Webhook-dispatched
// package runs see a FILTERED secret list, so "is the secret saved?" cannot be
// answered reliably from every runtime context — a pre-check that throws there
// blocks callers whose {{secret:...}} placeholder would have resolved fine at
// fetch time (this bit @kentcdodds/sentry-triage in production). Missing keys
// now surface as a 401 CursorApiError with the setup hint instead.

export type CursorRequestInput = {
	path: string
	method?: string
	query?: Record<string, string | number | boolean | undefined | null>
	body?: unknown
	headers?: Record<string, string>
	accept?: string
}

export type CursorRequestResult = {
	status: number
	body: unknown
	headers: Record<string, string>
}

/** Compact authenticated request to api.cursor.com (raw Response parsed). */
export async function cursorRequestRaw(
	input: CursorRequestInput,
): Promise<CursorRequestResult> {
	const url = new URL(trimString(input.path), CURSOR_BASE_URL)
	if (input.query) {
		for (const [key, value] of Object.entries(input.query)) {
			if (value === undefined || value === null || value === '') continue
			url.searchParams.set(key, String(value))
		}
	}

	const headers = new Headers({
		Accept: input.accept || 'application/json',
		Authorization: CURSOR_AUTH,
		'User-Agent': 'kody-cursor/2.0',
	})
	if (input.headers) {
		for (const [key, value] of Object.entries(input.headers)) {
			if (value) headers.set(key, value)
		}
	}

	const init: RequestInit = {
		method: trimString(input.method) || 'GET',
		headers,
	}
	if (input.body !== undefined) {
		if (!headers.has('Content-Type')) {
			headers.set('Content-Type', 'application/json')
		}
		init.body =
			typeof input.body === 'string' ? input.body : JSON.stringify(input.body)
	}

	const response = await fetch(url.toString(), init)
	const responseHeaders: Record<string, string> = {}
	response.headers.forEach((value, key) => {
		responseHeaders[key] = value
	})

	const text = await response.text()
	let body: unknown = null
	if (response.status !== 204 && text.trim()) {
		const contentType = response.headers.get('content-type') || ''
		if (contentType.includes('application/json')) {
			try {
				body = JSON.parse(text)
			} catch {
				body = text
			}
		} else {
			body = text
		}
	}

	return { status: response.status, body, headers: responseHeaders }
}

export async function cursorApi<T = unknown>(
	input: CursorRequestInput,
): Promise<T> {
	const result = await cursorRequestRaw(input)
	if (result.status < 200 || result.status >= 300) {
		throw new CursorApiError(result.status, result.body)
	}
	return result.body as T
}