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