/** 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
}