Skip to content

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

Package listing

@kentcdodds/cursor

src/agents.ts

516 lines · 14.2 KB · TypeScript
import { clampInt, cursorApi, trimString } from './lib/client.ts'

const GH_TOKEN_NAME = 'githubPersonalAccessToken'
/** Unscoped placeholder; resolves to the user-scoped secret. */
const GH_TOKEN_PLACEHOLDER = '{{secret:githubPersonalAccessToken}}'

export type PromptInput = {
	text: string
	images?: Array<
		| { data: string; mimeType: string }
		| { url: string }
	>
}

export type ModelSelection = {
	id: string
	params?: Array<{ id: string; value: string }>
}

export type RepoConfig = {
	url: string
	startingRef?: string
	prUrl?: string
}

export type CreateAgentParams = {
	prompt: PromptInput | string
	model?: ModelSelection | string
	name?: string
	repos?: RepoConfig[]
	repository?: string
	ref?: string
	prUrl?: string
	workOnCurrentBranch?: boolean
	skipReviewerRequest?: boolean
	mode?: 'agent' | 'plan'
	agentId?: string
	env?: { type: string; name?: string }
	envVars?: Record<string, string>
	mcpServers?: unknown[]
	customSubagents?: unknown[]
	[key: string]: unknown
}

export type ListAgentsParams = {
	limit?: number
	cursor?: string
	prUrl?: string
	includeArchived?: boolean
}

export type AgentSummary = {
	id: string
	name?: string
	status?: string
	url?: string
	createdAt?: string
	updatedAt?: string
	latestRunId?: string
	env?: { type?: string; name?: string }
	repos?: RepoConfig[]
	workOnCurrentBranch?: boolean
	autoCreatePR?: boolean
	[key: string]: unknown
}

export type ListAgentsResult = {
	items: AgentSummary[]
	nextCursor?: string
	[key: string]: unknown
}

export type CreateAgentResult = {
	agent: AgentSummary
	run: { id: string; agentId?: string; status?: string; [key: string]: unknown }
	[key: string]: unknown
}

function normalizePrompt(
	prompt: PromptInput | string | undefined,
): PromptInput {
	if (typeof prompt === 'string') return { text: trimString(prompt) }
	if (prompt && typeof prompt === 'object' && trimString(prompt.text)) {
		return prompt
	}
	throw new Error('prompt.text is required')
}

function normalizeModel(
	model: ModelSelection | string | undefined,
): ModelSelection | undefined {
	if (!model) return undefined
	if (typeof model === 'string') return { id: trimString(model) }
	return model
}

function buildRepos(params: CreateAgentParams): RepoConfig[] | undefined {
	if (Array.isArray(params.repos) && params.repos.length > 0) {
		return params.repos
	}
	const prUrl = trimString(params.prUrl)
	const repository = trimString(params.repository)
	if (prUrl) {
		const url = repository || prUrl.replace(/\/pull\/\d+.*$/, '')
		return [{ url, prUrl }]
	}
	if (repository) {
		const repo: RepoConfig = { url: repository }
		const ref = trimString(params.ref)
		if (ref) repo.startingRef = ref
		return [repo]
	}
	return undefined
}

function buildCreateBody(params: CreateAgentParams): Record<string, unknown> {
	const body: Record<string, unknown> = {
		prompt: normalizePrompt(params.prompt),
	}

	const model = normalizeModel(params.model)
	if (model) body.model = model

	const name = trimString(params.name)
	if (name) body.name = name

	const repos = buildRepos(params)
	if (repos) body.repos = repos

	// Always enable ManagePullRequest.
	body.autoCreatePR = true
	if (params.workOnCurrentBranch !== undefined) {
		body.workOnCurrentBranch = params.workOnCurrentBranch
	}
	if (params.skipReviewerRequest !== undefined) {
		body.skipReviewerRequest = params.skipReviewerRequest
	}
	if (params.mode) body.mode = params.mode
	if (params.agentId) body.agentId = params.agentId
	if (params.env) body.env = params.env
	if (params.envVars) body.envVars = params.envVars
	if (params.mcpServers) body.mcpServers = params.mcpServers
	if (params.customSubagents) body.customSubagents = params.customSubagents

	return body
}

/**
 * Create a Cloud Agent and enqueue its initial run.
 *
 * Always enables the agent's ManagePullRequest tools.
 *
 * @param params.prompt - Task instruction text (or `{ text, images? }`).
 * @param params.repos - Repository config; or pass `repository`/`ref`/`prUrl` shortcuts.
 * @returns The durable agent and initial run.
 * @example
 * import { createAgent } from 'kody:@kentcdodds/cursor/agents'
 * const { agent, run } = await createAgent({
 *   prompt: 'Add a README',
 *   repository: 'https://github.com/org/repo',
 *   ref: 'main',
 * })
 * // => { agent: { id: 'bc-...' }, run: { id: 'run-...' } }
 */
export async function createAgent(
	params: CreateAgentParams,
): Promise<CreateAgentResult> {
	return cursorApi<CreateAgentResult>({
		method: 'POST',
		path: '/v1/agents',
		body: buildCreateBody(params),
	})
}

/**
 * List agents for the authenticated user, newest first.
 *
 * @param params.limit - Page size (1–100); default 20.
 * @param params.cursor - Pagination cursor from a prior response.
 * @returns Agent summaries and optional `nextCursor`.
 * @example
 * import { listAgents } from 'kody:@kentcdodds/cursor/agents'
 * const { items } = await listAgents({ limit: 20 })
 * // => { items: [{ id: 'bc-...', name: '...', status: 'ACTIVE' }] }
 */
export async function listAgents(
	params: ListAgentsParams = {},
): Promise<ListAgentsResult> {
	const query: Record<string, string | number | boolean> = {}
	const limit = params.limit
	if (limit !== undefined) query.limit = clampInt(limit, 1, 100, 20)
	if (params.cursor) query.cursor = params.cursor
	if (params.prUrl) query.prUrl = params.prUrl
	if (params.includeArchived !== undefined) {
		query.includeArchived = params.includeArchived
	}
	return cursorApi<ListAgentsResult>({ path: '/v1/agents', query })
}

/**
 * Retrieve full metadata for one agent.
 *
 * @param params.id - Agent id (e.g. `bc-...`).
 * @returns Full agent record including repos and latestRunId.
 * @example
 * import { getAgent } from 'kody:@kentcdodds/cursor/agents'
 * const agent = await getAgent({ id: 'bc-00000000-0000-0000-0000-000000000001' })
 * // => { id: 'bc-...', repos: [...], latestRunId: 'run-...' }
 */
export async function getAgent(params: {
	id: string
}): Promise<AgentSummary> {
	const id = trimString(params.id)
	if (!id) throw new Error('id is required')
	return cursorApi<AgentSummary>({ path: `/v1/agents/${id}` })
}

/**
 * Archive an agent (reversible soft-delete).
 *
 * @param params.id - Agent id to archive.
 * @returns Confirmation with agent id.
 * @example
 * import { archiveAgent } from 'kody:@kentcdodds/cursor/agents'
 * await archiveAgent({ id: 'bc-00000000-0000-0000-0000-000000000001' })
 * // => { id: 'bc-...' }
 */
export async function archiveAgent(params: { id: string }) {
	const id = trimString(params.id)
	if (!id) throw new Error('id is required')
	return cursorApi({ method: 'POST', path: `/v1/agents/${id}/archive` })
}

/**
 * Unarchive an agent so it can accept new runs.
 *
 * @param params.id - Agent id to unarchive.
 * @returns Confirmation with agent id.
 * @example
 * import { unarchiveAgent } from 'kody:@kentcdodds/cursor/agents'
 * await unarchiveAgent({ id: 'bc-00000000-0000-0000-0000-000000000001' })
 * // => { id: 'bc-...' }
 */
export async function unarchiveAgent(params: { id: string }) {
	const id = trimString(params.id)
	if (!id) throw new Error('id is required')
	return cursorApi({ method: 'POST', path: `/v1/agents/${id}/unarchive` })
}

/**
 * Permanently delete an agent (irreversible).
 *
 * @param params.id - Agent id to delete.
 * @returns Confirmation with agent id.
 * @example
 * import { deleteAgent } from 'kody:@kentcdodds/cursor/agents'
 * await deleteAgent({ id: 'bc-00000000-0000-0000-0000-000000000001' })
 * // => { id: 'bc-...' }
 */
export async function deleteAgent(params: { id: string }) {
	const id = trimString(params.id)
	if (!id) throw new Error('id is required')
	return cursorApi({ method: 'DELETE', path: `/v1/agents/${id}` })
}

export type AgentStatusOverviewParams = {
	limit?: number
	recentFinishedLimit?: number
}

async function ghFetch(path: string) {
	// No secret_list pre-check (see lib/client.ts): filtered secret listings in
	// webhook-dispatched runtimes make it unreliable; 401 carries the hint.
	const response = await fetch(`https://api.github.com${path}`, {
		headers: {
			Authorization: `token ${GH_TOKEN_PLACEHOLDER}`,
			Accept: 'application/vnd.github.v3+json',
			'User-Agent': 'kody-cursor/1.0',
		},
	})
	if (!response.ok) {
		const hint =
			response.status === 401
				? ` (check that the "${GH_TOKEN_NAME}" user secret is saved with host approval for api.github.com)`
				: ''
		throw new Error(
			`GitHub API ${response.status}${hint}: ${(await response.text()).slice(0, 200)}`,
		)
	}
	return response.json()
}

/**
 * Summarize agent counts by status plus active and recently finished agents.
 *
 * @param params.limit - Agents to inspect (1–100); default 50.
 * @param params.recentFinishedLimit - Finished agents to include; default 10.
 * @returns Status histogram, active agents, and recent finished list.
 * @example
 * import { agentStatusOverview } from 'kody:@kentcdodds/cursor/agents'
 * const overview = await agentStatusOverview({ limit: 30 })
 * // => { inspected: 30, countsByStatus: { ACTIVE: 2 }, activeAgents: [...] }
 */
export async function agentStatusOverview(
	params: AgentStatusOverviewParams = {},
) {
	const limit = clampInt(params.limit ?? 50, 1, 100, 50)
	const recentFinishedLimit = clampInt(
		params.recentFinishedLimit ?? 10,
		1,
		25,
		10,
	)

	const { items: agents, nextCursor } = await listAgents({ limit })
	const countsByStatus: Record<string, number> = {}
	const knownStatuses = new Set([
		'ACTIVE',
		'ARCHIVED',
		'CREATING',
		'RUNNING',
		'FINISHED',
		'ERROR',
		'EXPIRED',
	])

	for (const agent of agents) {
		const status = String(agent.status || 'UNKNOWN').toUpperCase()
		countsByStatus[status] = (countsByStatus[status] || 0) + 1
	}

	const activeAgents = agents
		.filter((agent) => {
			const status = String(agent.status || '').toUpperCase()
			return status === 'ACTIVE' || status === 'CREATING' || status === 'RUNNING'
		})
		.map((agent) => ({
			id: agent.id || null,
			name: agent.name || null,
			status: agent.status || null,
			url: agent.url || null,
			latestRunId: agent.latestRunId || null,
			createdAt: agent.createdAt || null,
			updatedAt: agent.updatedAt || null,
		}))

	const recentFinished = agents
		.filter((agent) => String(agent.status || '').toUpperCase() === 'FINISHED')
		.sort((left, right) =>
			String(right.createdAt || '').localeCompare(String(left.createdAt || '')),
		)
		.slice(0, recentFinishedLimit)
		.map((agent) => ({
			id: agent.id || null,
			name: agent.name || null,
			status: agent.status || null,
			url: agent.url || null,
			latestRunId: agent.latestRunId || null,
			createdAt: agent.createdAt || null,
		}))

	const unexpected = agents
		.filter(
			(agent) =>
				!knownStatuses.has(String(agent.status || 'UNKNOWN').toUpperCase()),
		)
		.map((agent) => ({
			id: agent.id || null,
			name: agent.name || null,
			status: agent.status || null,
			url: agent.url || null,
			createdAt: agent.createdAt || null,
		}))

	return {
		inspected: agents.length,
		countsByStatus,
		activeAgents,
		recentFinished,
		unexpected,
		nextCursor: nextCursor || null,
	}
}

export type AgentsWithOpenGithubPrsParams = {
	limit?: number
	includeClosed?: boolean
}

/**
 * List agents tied to open GitHub pull requests (enriched via GitHub API).
 *
 * @param params.limit - Agents to inspect; default 50.
 * @param params.includeClosed - Include closed/merged PRs when `true`.
 * @returns Agents with PR metadata from GitHub.
 * @example
 * import { agentsWithOpenGithubPrs } from 'kody:@kentcdodds/cursor/agents'
 * const result = await agentsWithOpenGithubPrs({ limit: 20 })
 * // => { pullRequests: [{ agentId: 'bc-...', prState: 'OPEN' }] }
 */
export async function agentsWithOpenGithubPrs(
	params: AgentsWithOpenGithubPrsParams = {},
) {
	const limit = clampInt(params.limit ?? 50, 1, 100, 50)
	const includeClosed = params.includeClosed === true

	const { items: agents } = await listAgents({ limit })

	const prAgents: Array<{
		agent: AgentSummary
		prUrl: string
	}> = []

	for (const agent of agents) {
		let prUrl: string | null = null
		if (Array.isArray(agent.repos)) {
			for (const repo of agent.repos) {
				if (repo && typeof repo.prUrl === 'string' && repo.prUrl) {
					prUrl = repo.prUrl
					break
				}
			}
		}
		if (!prUrl) {
			const detail = await getAgent({ id: agent.id }).catch(() => null)
			if (detail && Array.isArray(detail.repos)) {
				for (const repo of detail.repos) {
					if (repo && typeof repo.prUrl === 'string' && repo.prUrl) {
						prUrl = repo.prUrl
						break
					}
				}
			}
		}
		if (prUrl) prAgents.push({ agent, prUrl })
	}

	const pullRequests: Array<Record<string, unknown>> = []
	for (const { agent, prUrl } of prAgents) {
		const match = prUrl.match(
			/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/)?$/,
		)

		if (!match) {
			if (includeClosed) {
				pullRequests.push({
					agentId: agent.id || null,
					agentName: agent.name || null,
					agentStatus: agent.status || null,
					prUrl,
					prState: 'UNKNOWN',
					parseError: 'Could not parse the GitHub pull request URL.',
				})
			}
			continue
		}

		const owner = match[1]
		const repo = match[2]
		const number = match[3]

		try {
			const pr = (await ghFetch(
				`/repos/${owner}/${repo}/pulls/${number}`,
			)) as {
				merged_at?: string | null
				state?: string
				draft?: boolean
				title?: string
				updated_at?: string
			}
			const mergedAt = pr.merged_at || null
			const prState = mergedAt ? 'MERGED' : String(pr.state || 'UNKNOWN').toUpperCase()

			if (includeClosed || prState === 'OPEN') {
				pullRequests.push({
					agentId: agent.id || null,
					agentName: agent.name || null,
					agentStatus: agent.status || null,
					repository: `${owner}/${repo}`,
					prNumber: Number(number),
					prUrl,
					prState,
					draft: Boolean(pr.draft),
					title: pr.title || null,
					mergedAt,
					updatedAt: pr.updated_at || null,
				})
			}
		} catch (error) {
			if (includeClosed) {
				pullRequests.push({
					agentId: agent.id || null,
					agentName: agent.name || null,
					agentStatus: agent.status || null,
					repository: `${owner}/${repo}`,
					prNumber: Number(number),
					prUrl,
					prState: 'UNKNOWN',
					error: error instanceof Error ? error.message : String(error),
				})
			}
		}
	}

	return {
		inspectedAgents: agents.length,
		agentsWithPrs: prAgents.length,
		returned: pullRequests.length,
		includeClosed,
		pullRequests,
	}
}

/** Default export: list agents (most common read). */
export default listAgents