Skip to content

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

Package listing

@kentcdodds/cursor

src/artifacts.ts

113 lines · 3.1 KB · TypeScript
import { cursorApi, trimString } from './lib/client.ts'

export type ArtifactItem = {
	path: string
	sizeBytes?: number
	updatedAt?: string
	[key: string]: unknown
}

export type ListArtifactsResult = {
	items: ArtifactItem[]
	[key: string]: unknown
}

function requireAgentId(agentId: string | undefined): string {
	const id = trimString(agentId)
	if (!id) throw new Error('agentId is required')
	return id
}

/**
 * List artifacts produced by an agent.
 *
 * @param params.agentId - Agent id.
 * @returns Artifact paths relative to the workspace `artifacts/` directory.
 * @example
 * import { listArtifacts } from 'kody:@kentcdodds/cursor/artifacts'
 * const { items } = await listArtifacts({ agentId: 'bc-...' })
 * // => { items: [{ path: 'artifacts/screenshot.png', sizeBytes: 12345 }] }
 */
export async function listArtifacts(params: {
	agentId: string
}): Promise<ListArtifactsResult> {
	const agentId = requireAgentId(params.agentId)
	return cursorApi<ListArtifactsResult>({
		path: `/v1/agents/${agentId}/artifacts`,
	})
}

const TEXT_TYPES = new Set([
	'text/plain',
	'text/html',
	'text/css',
	'text/csv',
	'application/json',
	'application/xml',
	'text/markdown',
	'application/javascript',
])

function isTextualContentType(contentType: string): boolean {
	const base = contentType.split(';')[0].trim().toLowerCase()
	return TEXT_TYPES.has(base) || base.startsWith('text/')
}

export type DownloadArtifactResult =
	| { contentType: string; text: string }
	| { contentType: string; base64: string }

/**
 * Download an artifact by path (fetches presigned URL content).
 *
 * @param params.agentId - Agent id.
 * @param params.path - Relative artifact path from listArtifacts.
 * @returns Text for textual content, or base64 for binary.
 * @example
 * import { downloadArtifact } from 'kody:@kentcdodds/cursor/artifacts'
 * const file = await downloadArtifact({
 *   agentId: 'bc-...',
 *   path: 'artifacts/screenshot.png',
 * })
 * // => { contentType: 'image/png', base64: '...' }
 */
export async function downloadArtifact(params: {
	agentId: string
	path: string
}): Promise<DownloadArtifactResult> {
	const agentId = requireAgentId(params.agentId)
	const path = trimString(params.path)
	if (!path) throw new Error('path is required')

	const presign = (await cursorApi<{ url?: string; expiresAt?: string }>({
		path: `/v1/agents/${agentId}/artifacts/download`,
		query: { path },
	})) as { url?: string }

	const url = trimString(presign.url)
	if (!url) throw new Error('Artifact download URL was not returned')

	const response = await fetch(url)
	if (!response.ok) {
		throw new Error(`Artifact fetch ${response.status}`)
	}

	const contentType =
		response.headers.get('content-type') || 'application/octet-stream'
	const buffer = await response.arrayBuffer()

	if (isTextualContentType(contentType)) {
		const text = new TextDecoder().decode(buffer)
		return { contentType, text }
	}

	const bytes = new Uint8Array(buffer)
	let binary = ''
	for (let i = 0; i < bytes.length; i++) {
		binary += String.fromCharCode(bytes[i])
	}
	return { contentType, base64: btoa(binary) }
}

/** Default export: list artifacts for an agent. */
export default listArtifacts