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}}' /** * Prompt for createAgent / createRun. * * `images` is vision-only (what the model sees). It does **not** place usable * file bytes on the cloud agent VM. For binary/file handoffs, mint a * `downloadUrl` via `kody:@kentcdodds/agent-files/create-upload`, PUT the * file, then include that URL in `text` so the agent can curl/download it. */ export type PromptInput = { text: string /** Vision-only attachments for the model. Not a byte handoff to the VM. */ 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 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 { const body: Record = { 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 ManagePullRequest. When the task changes code, the agent must * push and open/update the PR itself via ManagePullRequest before finishing — * do not open the PR through Kody. Author as the Kent C. Dodds Cursor/GitHub * account (triggers the standard AI reviewer). Put that requirement in every * createAgent prompt. * * For binary/file handoffs to the cloud agent VM, mint `downloadUrl` via * `kody:@kentcdodds/agent-files/create-upload`, PUT the file to `upload.url`, * then include that URL in the prompt text — do not depend on `prompt.images` * for bytes (`images` is vision-only). * * @param params.prompt - Task instruction text (or `{ text, images? }`). Prefer putting agent-files `downloadUrl`s in `text`. `images` is vision-only. * @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 { return cursorApi({ 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 { const query: Record = {} 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({ 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 { const id = trimString(params.id) if (!id) throw new Error('id is required') return cursorApi({ 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 = {} 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> = [] 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