Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/cursor

src/lib/client.ts

133 lines · 4.0 KB · TypeScript
/** 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'

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

	constructor(status: number, body: unknown, message?: string) {
		const detail =
			body && typeof body === 'object' && 'message' in body
				? String((body as { message: unknown }).message)
				: ''
		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
}