From 9b3829d700c38e0bec36f8c4f511b62b8087278f Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Thu, 7 May 2026 12:26:11 +0530 Subject: [PATCH 01/18] feat(agents): expand AR CLI surface with new commands, filters, and live model validation --- src/commands/agents/agents-archive.ts | 36 ++ src/commands/agents/agents-commit.ts | 59 +++ src/commands/agents/agents-create.ts | 310 +++++++++------ src/commands/agents/agents-diff.ts | 105 +++++ src/commands/agents/agents-follow-up.ts | 127 ++++++ src/commands/agents/agents-list.ts | 244 ++++++------ src/commands/agents/agents-open.ts | 69 ++++ src/commands/agents/agents-pr.ts | 43 ++ src/commands/agents/agents-publish.ts | 60 +++ src/commands/agents/agents-redeploy.ts | 61 +++ src/commands/agents/agents-revert.ts | 55 +++ src/commands/agents/agents-show.ts | 508 ++++++++++++++++++------ src/commands/agents/agents-stop.ts | 193 +++++---- src/commands/agents/agents.ts | 201 +++++++++- src/commands/agents/api.ts | 233 +++++++++++ src/commands/agents/attachments.ts | 64 +++ src/commands/agents/constants.ts | 40 +- src/commands/agents/types.ts | 158 +++++++- src/commands/agents/utils.ts | 134 ++++++- 19 files changed, 2227 insertions(+), 473 deletions(-) create mode 100644 src/commands/agents/agents-archive.ts create mode 100644 src/commands/agents/agents-commit.ts create mode 100644 src/commands/agents/agents-diff.ts create mode 100644 src/commands/agents/agents-follow-up.ts create mode 100644 src/commands/agents/agents-open.ts create mode 100644 src/commands/agents/agents-pr.ts create mode 100644 src/commands/agents/agents-publish.ts create mode 100644 src/commands/agents/agents-redeploy.ts create mode 100644 src/commands/agents/agents-revert.ts create mode 100644 src/commands/agents/api.ts create mode 100644 src/commands/agents/attachments.ts diff --git a/src/commands/agents/agents-archive.ts b/src/commands/agents/agents-archive.ts new file mode 100644 index 00000000000..5d49472c7be --- /dev/null +++ b/src/commands/agents/agents-archive.ts @@ -0,0 +1,36 @@ +import type { OptionValues } from 'commander' + +import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' + +interface AgentArchiveOptions extends OptionValues { + json?: boolean +} + +export const agentsArchive = async (id: string, options: AgentArchiveOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + await command.authenticate() + const api = createAgentsApi(command.netlify) + + const spinner = startSpinner({ text: 'Archiving agent task...' }) + try { + await api.archiveAgentRunner(id) + stopSpinner({ spinner }) + + const result = { success: true, id } + if (options.json) { + logJson(result) + return result + } + + log(`${chalk.green('✓')} Agent task archived.`) + log(` Task ID: ${chalk.cyan(id)}`) + return result + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to archive: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-commit.ts b/src/commands/agents/agents-commit.ts new file mode 100644 index 00000000000..4db95abfa7b --- /dev/null +++ b/src/commands/agents/agents-commit.ts @@ -0,0 +1,59 @@ +import type { OptionValues } from 'commander' +import inquirer from 'inquirer' + +import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' + +interface AgentCommitOptions extends OptionValues { + branch?: string + json?: boolean +} + +export const agentsCommit = async (id: string, options: AgentCommitOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + await command.authenticate() + const api = createAgentsApi(command.netlify) + + let targetBranch = options.branch + if (!targetBranch) { + if (!process.stdout.isTTY) { + return logAndThrowError('--branch is required when stdin is not a TTY') + } + const { branchInput } = await inquirer.prompt<{ branchInput: string }>([ + { + type: 'input', + name: 'branchInput', + message: 'Which branch should the agent commit to?', + validate: (input: string) => (input.trim().length > 0 ? true : 'Branch name is required'), + }, + ]) + targetBranch = branchInput.trim() + } + + const spinner = startSpinner({ text: `Committing to ${targetBranch}...` }) + try { + const runner = await api.agentRunnerCommitToBranch(id, targetBranch) + stopSpinner({ spinner }) + + if (options.json) { + logJson(runner) + return runner + } + + if (runner.merge_commit_error) { + log(`${chalk.red('✗')} Commit failed: ${runner.merge_commit_error}`) + return runner + } + + log(`${chalk.green('✓')} Committed to ${chalk.cyan(targetBranch)}`) + log() + if (runner.merge_commit_sha) log(` SHA: ${chalk.cyan(runner.merge_commit_sha)}`) + return runner + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to commit: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-create.ts b/src/commands/agents/agents-create.ts index 53fcd870193..8db4d641706 100644 --- a/src/commands/agents/agents-create.ts +++ b/src/commands/agents/agents-create.ts @@ -1,134 +1,146 @@ +import { execSync } from 'child_process' + import type { OptionValues } from 'commander' import inquirer from 'inquirer' -import { chalk, logAndThrowError, log, logJson } from '../../utils/command-helpers.js' +import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' -import type { AgentRunner } from './types.js' -import { validatePrompt, validateAgent, formatStatus, getAgentName } from './utils.js' -import { AVAILABLE_AGENTS } from './constants.js' +import { createAgentsApi } from './api.js' +import { AVAILABLE_AGENTS, type AvailableAgent, type UserSelectableMode } from './constants.js' +import { uploadAttachments, type UploadedAttachment } from './attachments.js' +import type { CreateAgentRunnerPayload } from './types.js' +import { + checkModelAvailability, + formatBytes, + formatStatus, + getAgentName, + validateAgent, + validateMode, + validatePrompt, +} from './utils.js' interface AgentCreateOptions extends OptionValues { prompt?: string agent?: string branch?: string model?: string + mode?: string + fromDeploy?: string + parent?: string + devServerImage?: string + attach?: string[] + json?: boolean +} + +interface LocalGitInfo { + branch?: string + isDirty?: boolean + hasUnpushedCommits?: boolean + isInsideRepo: boolean +} + +const detectLocalGit = (): LocalGitInfo => { + const run = (command: string): string => + execSync(command, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim() + try { + run('git rev-parse --is-inside-work-tree') + } catch { + return { isInsideRepo: false } + } + let branch: string | undefined + try { + const head = run('git rev-parse --abbrev-ref HEAD') + if (head && head !== 'HEAD') branch = head + } catch { + // Ignore + } + let isDirty: boolean | undefined + try { + isDirty = run('git status --porcelain').length > 0 + } catch { + // Ignore + } + let hasUnpushedCommits: boolean | undefined + if (branch) { + try { + const upstream = run('git rev-parse --abbrev-ref --symbolic-full-name @{u}') + if (upstream) { + const ahead = run(`git rev-list --count ${upstream}..HEAD`) + hasUnpushedCommits = Number.parseInt(ahead, 10) > 0 + } + } catch { + // No upstream configured: can't tell. + } + } + return { isInsideRepo: true, branch, isDirty, hasUnpushedCommits } } export const agentsCreate = async (promptArg: string, options: AgentCreateOptions, command: BaseCommand) => { - const { api, site, siteInfo, apiOpts } = command.netlify + const { site, siteInfo } = command.netlify await command.authenticate() - const { prompt, agent: initialAgent, branch: initialBranch, model } = options + if (options.fromDeploy && options.branch) { + return logAndThrowError('--from-deploy and --branch are mutually exclusive') + } + + if (options.attach && options.attach.length > 0 && !siteInfo.account_id) { + return logAndThrowError('Cannot attach files: no account ID is available for this site') + } + + if (options.mode) { + const valid = validateMode(options.mode) + if (valid !== true) return logAndThrowError(valid) + } - let finalPrompt: string - let agent = initialAgent - let branch = initialBranch + const finalPrompt = await resolvePrompt(promptArg, options.prompt) + const agent = await resolveAgent(options.agent) const isGitBased = Boolean(siteInfo.build_settings?.repo_branch) + let branch: string | undefined - // Interactive prompt if not provided - if (!prompt && !promptArg) { - const { promptInput } = await inquirer.prompt<{ - promptInput: string - }>([ - { - type: 'input', - name: 'promptInput', - message: 'What would you like the agent to do?', - validate: validatePrompt, - }, - ]) - finalPrompt = promptInput - } else { - finalPrompt = (promptArg || prompt) ?? '' + if (isGitBased && !options.fromDeploy) { + branch = await resolveBranch(options.branch, siteInfo.build_settings?.repo_branch) } - const promptIsValid = validatePrompt(finalPrompt) - if (promptIsValid !== true) { - return logAndThrowError(promptIsValid) - } + const api = createAgentsApi(command.netlify) - // Agent selection if not provided - if (!agent) { - const { agentInput } = await inquirer.prompt<{ - agentInput: string - }>([ - { - type: 'list', - name: 'agentInput', - message: 'Which agent would you like to use?', - choices: AVAILABLE_AGENTS, - default: 'claude', - }, - ]) - agent = agentInput - } else { - const agentIsValid = validateAgent(agent) - if (agentIsValid !== true) { - return logAndThrowError(agentIsValid) + if (options.model) { + const valid = await checkModelAvailability(api, agent, options.model) + if (valid !== true) log(chalk.yellow(`⚠ ${valid}`)) + } + let attachments: UploadedAttachment[] = [] + if (options.attach && options.attach.length > 0 && siteInfo.account_id) { + const uploadSpinner = startSpinner({ text: `Uploading ${options.attach.length.toString()} attachment(s)...` }) + try { + attachments = await uploadAttachments(api, siteInfo.account_id, options.attach) + stopSpinner({ spinner: uploadSpinner }) + for (const file of attachments) { + log(` ${chalk.green('✓')} ${file.filename} ${chalk.dim(`(${formatBytes(file.size)})`)}`) + } + } catch (error_) { + stopSpinner({ spinner: uploadSpinner, error: true }) + const error = error_ as Error + return logAndThrowError(error.message) } } - if (isGitBased) { - if (!branch) { - const defaultBranch = siteInfo.build_settings?.repo_branch - - const { branchInput } = await inquirer.prompt<{ - branchInput: string - }>([ - { - type: 'input', - name: 'branchInput', - message: 'Which branch would you like to work on?', - default: defaultBranch, - validate: (input: string) => { - if (!input || input.trim().length === 0) { - return 'Branch name is required' - } - return true - }, - }, - ]) - - branch = branchInput.trim() - } - } else { - branch = undefined + const payload: CreateAgentRunnerPayload = { + prompt: finalPrompt, + agent, + model: options.model, + branch, + deploy_id: options.fromDeploy, + parent_agent_runner_id: options.parent, + mode: options.mode as UserSelectableMode | undefined, + dev_server_image: options.devServerImage, + file_keys: attachments.length > 0 ? attachments.map((entry) => entry.fileKey) : undefined, } const createSpinner = startSpinner({ text: 'Creating agent task...' }) - try { - // Create the agent runner using the same API format as the React UI - const createParams = new URLSearchParams() - createParams.set('site_id', site.id ?? '') - - const response = await fetch( - `${apiOpts.scheme ?? 'https'}://${apiOpts.host ?? api.host}/api/v1/agent_runners?${createParams.toString()}`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${api.accessToken ?? ''}`, - 'Content-Type': 'application/json', - 'User-Agent': apiOpts.userAgent, - }, - body: JSON.stringify({ - ...(branch ? { branch } : {}), - prompt: finalPrompt, - agent, - model, - }), - }, - ) - - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { error?: string } - throw new Error(errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`) - } - - const agentRunner = (await response.json()) as AgentRunner + const agentRunner = await api.createAgentRunner(site.id ?? '', payload) stopSpinner({ spinner: createSpinner }) if (options.json) { @@ -137,38 +149,100 @@ export const agentsCreate = async (promptArg: string, options: AgentCreateOption } log(`${chalk.green('✓')} Agent task created successfully!`) - log(``) + log() log(chalk.bold('Details:')) log(` Task ID: ${chalk.cyan(agentRunner.id)}`) log(` Prompt: ${chalk.dim(finalPrompt)}`) - log(` Agent: ${chalk.cyan(getAgentName(agent))}${model ? ` (${model})` : ''}`) - if (isGitBased && branch) { + log(` Agent: ${chalk.cyan(getAgentName(agent))}${options.model ? ` (${options.model})` : ''}`) + if (options.mode && options.mode !== 'normal') log(` Mode: ${chalk.cyan(options.mode)}`) + if (options.fromDeploy) { + log(` Base Deploy: ${chalk.cyan(options.fromDeploy)}`) + } else if (isGitBased && branch) { log(` Branch: ${chalk.cyan(branch)}`) } else { log(` Base: ${chalk.cyan('Latest production deployment')}`) } + if (options.parent) log(` Parent Task: ${chalk.cyan(options.parent)}`) + if (attachments.length > 0) log(` Attachments: ${attachments.length.toString()} file(s)`) log(` Status: ${formatStatus(agentRunner.state ?? 'new')}`) - log(``) + log() log(chalk.bold('Monitor progress:')) - log(` CLI: ${chalk.cyan(`netlify agents:show ${agentRunner.id}`)}`) - log( - ` View in browser: ${chalk.blue( - `https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${agentRunner.id}`, - )}`, - ) - log(``) - log( - chalk.dim( - 'Note: The agent task will run remotely on Netlify infrastructure and may take a few minutes to complete.', - ), - ) + log(` Watch: ${chalk.cyan(`netlify agents:show ${agentRunner.id} --watch`)}`) + log(` Show: ${chalk.cyan(`netlify agents:show ${agentRunner.id}`)}`) + log(` Browser: ${chalk.blue(`https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${agentRunner.id}`)}`) + log() + log(chalk.dim('The agent task runs remotely on Netlify infrastructure and may take a few minutes.')) return agentRunner } catch (error_) { + stopSpinner({ spinner: createSpinner, error: true }) const error = error_ as Error + return logAndThrowError(`Failed to create agent task: ${error.message}`) + } +} - stopSpinner({ spinner: createSpinner, error: true }) +const resolvePrompt = async (promptArg: string, promptFlag?: string): Promise => { + if (!promptArg && !promptFlag) { + const { promptInput } = await inquirer.prompt<{ promptInput: string }>([ + { + type: 'input', + name: 'promptInput', + message: 'What would you like the agent to do?', + validate: validatePrompt, + }, + ]) + return promptInput + } + const final = (promptArg || promptFlag) ?? '' + const valid = validatePrompt(final) + if (valid !== true) { + return logAndThrowError(valid) + } + return final +} - return logAndThrowError(`Failed to create agent task: ${error.message}`) +const resolveAgent = async (agentFlag?: string): Promise => { + if (!agentFlag) { + const { agentInput } = await inquirer.prompt<{ agentInput: AvailableAgent }>([ + { + type: 'list', + name: 'agentInput', + message: 'Which agent would you like to use?', + choices: AVAILABLE_AGENTS.map((entry) => ({ name: entry.name, value: entry.value })), + default: 'claude', + }, + ]) + return agentInput } + const valid = validateAgent(agentFlag) + if (valid !== true) return logAndThrowError(valid) + return agentFlag as AvailableAgent +} + +const resolveBranch = async (branchFlag: string | undefined, siteBranch: string | undefined): Promise => { + if (branchFlag) return branchFlag + + const localGit = detectLocalGit() + const defaultBranch = localGit.branch ?? siteBranch + + if (localGit.isInsideRepo) { + if (localGit.isDirty) { + log(chalk.yellow('⚠ Local working tree has uncommitted changes. The agent runs against the remote branch.')) + } + if (localGit.hasUnpushedCommits) { + log(chalk.yellow('⚠ Local branch has unpushed commits. The agent runs against the remote branch.')) + } + } + + const { branchInput } = await inquirer.prompt<{ branchInput: string }>([ + { + type: 'input', + name: 'branchInput', + message: 'Which branch would you like to work on?', + default: defaultBranch, + validate: (input: string) => (input.trim().length > 0 ? true : 'Branch name is required'), + }, + ]) + + return branchInput.trim() } diff --git a/src/commands/agents/agents-diff.ts b/src/commands/agents/agents-diff.ts new file mode 100644 index 00000000000..922871a3498 --- /dev/null +++ b/src/commands/agents/agents-diff.ts @@ -0,0 +1,105 @@ +import type { OptionValues } from 'commander' + +import { chalk, log, logAndThrowError } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' +import { formatDiff } from './utils.js' + +interface AgentDiffOptions extends OptionValues { + page?: string + perPage?: string + session?: string + cumulative?: boolean + stripBinary?: boolean + color?: boolean +} + +const parsePositiveInt = (input: string | undefined, name: string): number | undefined => { + if (input === undefined) return undefined + const value = Number.parseInt(input, 10) + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`--${name} must be a positive integer`) + } + return value +} + +export const agentsDiff = async (id: string, options: AgentDiffOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + await command.authenticate() + const api = createAgentsApi(command.netlify) + + const useColor = options.color !== false && process.stdout.isTTY + + if (options.session) { + const kind = options.cumulative ? 'cumulative' : 'result' + const spinner = startSpinner({ text: `Fetching session ${kind} diff...` }) + try { + const diff = options.cumulative + ? await api.getSessionCumulativeDiff(id, options.session) + : await api.getSessionResultDiff(id, options.session) + stopSpinner({ spinner }) + if (!diff) { + log(chalk.yellow('No diff available for this session.')) + return + } + process.stdout.write(useColor ? formatDiff(diff) : diff) + if (!diff.endsWith('\n')) process.stdout.write('\n') + return + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to fetch diff: ${error.message}`) + } + } + + let page: number | undefined + let perPage: number | undefined + try { + page = parsePositiveInt(options.page, 'page') ?? 1 + perPage = parsePositiveInt(options.perPage, 'per-page') + } catch (error_) { + return logAndThrowError((error_ as Error).message) + } + + const spinner = startSpinner({ text: 'Fetching agent task diff...' }) + try { + const result = await api.getAgentRunnerDiff(id, { + page, + per_page: perPage, + strip_binary: options.stripBinary !== false, + }) + stopSpinner({ spinner }) + + if (!result.data) { + log(chalk.yellow('No diff available for this agent task.')) + return + } + + process.stdout.write(useColor ? formatDiff(result.data) : result.data) + if (!result.data.endsWith('\n')) process.stdout.write('\n') + + log() + log(chalk.dim(formatFooter(result.page, result.perPage, result.total, result.hasNext))) + return result + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to fetch diff: ${error.message}`) + } +} + +const formatFooter = (page: number, perPage: number, total: number | undefined, hasNext: boolean): string => { + const parts: string[] = [] + if (total != null) { + const start = (page - 1) * perPage + 1 + const end = Math.min(page * perPage, total) + parts.push(`Showing files ${start.toString()}-${end.toString()} of ${total.toString()}`) + } else { + parts.push(`Showing page ${page.toString()}`) + } + if (hasNext) { + parts.push(`Use --page ${(page + 1).toString()} for the next page`) + } + return parts.join(' • ') +} diff --git a/src/commands/agents/agents-follow-up.ts b/src/commands/agents/agents-follow-up.ts new file mode 100644 index 00000000000..5d4c17739f1 --- /dev/null +++ b/src/commands/agents/agents-follow-up.ts @@ -0,0 +1,127 @@ +import type { OptionValues } from 'commander' +import inquirer from 'inquirer' + +import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' +import { uploadAttachments, type UploadedAttachment } from './attachments.js' +import { type AvailableAgent } from './constants.js' +import type { CreateAgentRunnerSessionPayload } from './types.js' +import { + checkModelAvailability, + formatBytes, + formatStatus, + getAgentName, + validateAgent, + validatePrompt, +} from './utils.js' + +interface AgentFollowUpOptions extends OptionValues { + prompt?: string + agent?: string + model?: string + devServerImage?: string + attach?: string[] + json?: boolean +} + +export const agentsFollowUp = async ( + id: string, + promptArg: string, + options: AgentFollowUpOptions, + command: BaseCommand, +) => { + if (!id) return logAndThrowError('Agent task ID is required') + await command.authenticate() + const { siteInfo } = command.netlify + const api = createAgentsApi(command.netlify) + + if (options.attach && options.attach.length > 0 && !siteInfo.account_id) { + return logAndThrowError('Cannot attach files: no account ID is available for this site') + } + + let finalPrompt = promptArg || options.prompt + if (!finalPrompt) { + const { promptInput } = await inquirer.prompt<{ promptInput: string }>([ + { + type: 'input', + name: 'promptInput', + message: 'What would you like the agent to do next?', + validate: validatePrompt, + }, + ]) + finalPrompt = promptInput + } + const promptValid = validatePrompt(finalPrompt) + if (promptValid !== true) return logAndThrowError(promptValid) + + let agent: AvailableAgent | undefined + if (options.agent) { + const valid = validateAgent(options.agent) + if (valid !== true) return logAndThrowError(valid) + agent = options.agent as AvailableAgent + } + if (options.model && agent) { + const valid = await checkModelAvailability(api, agent, options.model) + if (valid !== true) log(chalk.yellow(`⚠ ${valid}`)) + } + + let attachments: UploadedAttachment[] = [] + if (options.attach && options.attach.length > 0 && siteInfo.account_id) { + const uploadSpinner = startSpinner({ text: `Uploading ${options.attach.length.toString()} attachment(s)...` }) + try { + attachments = await uploadAttachments(api, siteInfo.account_id, options.attach) + stopSpinner({ spinner: uploadSpinner }) + for (const file of attachments) { + log(` ${chalk.green('✓')} ${file.filename} ${chalk.dim(`(${formatBytes(file.size)})`)}`) + } + } catch (error_) { + stopSpinner({ spinner: uploadSpinner, error: true }) + const error = error_ as Error + return logAndThrowError(error.message) + } + } + + const payload: CreateAgentRunnerSessionPayload = { + prompt: finalPrompt, + agent, + model: options.model, + dev_server_image: options.devServerImage, + file_keys: attachments.length > 0 ? attachments.map((entry) => entry.fileKey) : undefined, + } + + const spinner = startSpinner({ text: 'Sending follow-up prompt...' }) + try { + const session = await api.createAgentRunnerSession(id, payload) + stopSpinner({ spinner }) + + if (options.json) { + logJson(session) + return session + } + + log(`${chalk.green('✓')} Follow-up session created!`) + log() + log(chalk.bold('Details:')) + log(` Task ID: ${chalk.cyan(id)}`) + log(` Session ID: ${chalk.cyan(session.id)}`) + log(` Prompt: ${chalk.dim(finalPrompt)}`) + if (agent) log(` Agent: ${chalk.cyan(getAgentName(agent))}${options.model ? ` (${options.model})` : ''}`) + log(` Status: ${formatStatus(session.state)}`) + log() + log(chalk.bold('Monitor progress:')) + log(` Watch: ${chalk.cyan(`netlify agents:show ${id} --watch`)}`) + log(` Show: ${chalk.cyan(`netlify agents:show ${id}`)}`) + return session + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + if (error.message.toLowerCase().includes('active session')) { + log() + log(chalk.yellow('A session is already running on this task. Wait for it to finish or stop it first:')) + log(` ${chalk.cyan(`netlify agents:stop ${id}`)}`) + } + return logAndThrowError(`Failed to send follow-up: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-list.ts b/src/commands/agents/agents-list.ts index 7b17fabe1cb..c7f78cab216 100644 --- a/src/commands/agents/agents-list.ts +++ b/src/commands/agents/agents-list.ts @@ -1,119 +1,114 @@ import type { OptionValues } from 'commander' import AsciiTable from 'ascii-table' -import { chalk, logAndThrowError, log, logJson } from '../../utils/command-helpers.js' +import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' -import type { AgentRunner, AgentRunnerSession } from './types.js' -import { formatDuration, formatStatus, truncateText, getAgentName } from './utils.js' +import { createAgentsApi } from './api.js' +import type { AgentRunner, ListAgentRunnersFilters } from './types.js' +import { formatDuration, formatStatus, getAgentName, truncateText } from './utils.js' interface AgentListOptions extends OptionValues { status?: string json?: boolean + ndjson?: boolean + branch?: string + user?: string + title?: string + since?: string + until?: string + page?: string + perPage?: string + account?: string +} + +const toUnixSeconds = (input?: string): number | undefined => { + if (!input) return undefined + const parsed = Date.parse(input) + if (Number.isNaN(parsed)) { + throw new Error(`Invalid date "${input}". Use an ISO timestamp like 2026-05-01T00:00:00Z.`) + } + return Math.floor(parsed / 1000) +} + +const parsePositiveInt = (input: string | undefined, name: string): number | undefined => { + if (input === undefined) return undefined + const value = Number.parseInt(input, 10) + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`--${name} must be a positive integer`) + } + return value +} + +const buildFilters = (options: AgentListOptions): ListAgentRunnersFilters => { + const filters: ListAgentRunnersFilters = {} + if (options.status) { + if (options.status !== 'live' && options.status !== 'error') { + throw new Error('--status accepts only "live" or "error"') + } + filters.state = options.status + } + if (options.branch) filters.branch = options.branch + if (options.user) filters.user_id = options.user + if (options.title) filters.title = options.title + filters.from = toUnixSeconds(options.since) + filters.to = toUnixSeconds(options.until) + filters.page = parsePositiveInt(options.page, 'page') + filters.per_page = parsePositiveInt(options.perPage, 'per-page') + return filters } export const agentsList = async (options: AgentListOptions, command: BaseCommand) => { - const { api, site, siteInfo, apiOpts } = command.netlify + const { site, siteInfo } = command.netlify await command.authenticate() - const listSpinner = startSpinner({ text: 'Fetching agent tasks...' }) - + const api = createAgentsApi(command.netlify) + let filters: ListAgentRunnersFilters try { - const params = new URLSearchParams() - params.set('site_id', site.id ?? '') - params.set('page', '1') - params.set('per_page', '15') + filters = buildFilters(options) + } catch (error_) { + return logAndThrowError((error_ as Error).message) + } - if (options.status) { - params.set('state', options.status) - } + const spinner = startSpinner({ text: 'Fetching agent tasks...' }) - const response = await fetch( - `${apiOpts.scheme ?? 'https'}://${apiOpts.host ?? api.host}/api/v1/agent_runners?${params.toString()}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${api.accessToken ?? ''}`, - 'User-Agent': apiOpts.userAgent, - }, - }, - ) + try { + const result = options.account + ? await api.listAgentRunnersForAccount(options.account, filters) + : await api.listAgentRunners(site.id ?? '', filters) + stopSpinner({ spinner }) - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { error?: string } - throw new Error(errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`) + if (options.json) { + logJson(result.data) + return result.data } - const agentRunners = (await response.json()) as AgentRunner[] | null | undefined - stopSpinner({ spinner: listSpinner }) - - if (options.json) { - logJson(agentRunners) - return agentRunners + if (options.ndjson) { + for (const runner of result.data) { + process.stdout.write(`${JSON.stringify(runner)}\n`) + } + return result.data } - if (!agentRunners || agentRunners.length === 0) { + if (result.data.length === 0) { log(chalk.yellow('No agent tasks found for this site.')) - log(``) + log() log(`Create your first agent task with:`) log(` ${chalk.cyan('netlify agents:create')}`) - return + return result.data } - // Fetch agent info for each runner - const agentInfo = new Map() - const agentSpinner = startSpinner({ text: 'Loading agent information...' }) - - try { - // Fetch latest session for each runner in parallel to get agent info - const sessionPromises = agentRunners.map(async (runner) => { - try { - const sessionsResponse = await fetch( - `${apiOpts.scheme ?? 'https'}://${apiOpts.host ?? api.host}/api/v1/agent_runners/${ - runner.id - }/sessions?page=1&per_page=1`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${api.accessToken ?? ''}`, - 'User-Agent': apiOpts.userAgent, - }, - }, - ) - - if (sessionsResponse.ok) { - const sessions = (await sessionsResponse.json()) as AgentRunnerSession[] | undefined - if (sessions && sessions.length > 0 && sessions[0].agent_config) { - const { agent } = sessions[0].agent_config - if (agent) { - agentInfo.set(runner.id, agent) - } - } - } - } catch { - // Failed to fetch session for this runner, continue without agent info - } - }) - - // Wait for all session fetches to complete - await Promise.allSettled(sessionPromises) - stopSpinner({ spinner: agentSpinner }) - } catch { - // If parallel fetch fails entirely, continue without agent info - stopSpinner({ spinner: agentSpinner, error: true }) - } + const agentInfo = await fetchLatestAgentByRunner(api, result.data) const isGitBased = Boolean(siteInfo.build_settings?.repo_branch) - - // Create and populate table without colors for proper formatting - const table = new AsciiTable(`Agent Tasks for ${siteInfo.name}`) + const scope = options.account ? `account ${options.account}` : siteInfo.name + const table = new AsciiTable(`Agent Tasks for ${scope}`) const baseColumnLabel = isGitBased ? 'BRANCH' : 'BASE' table.setHeading('ID', 'STATUS', 'AGENT', 'PROMPT', baseColumnLabel, 'DURATION', 'CREATED') - agentRunners.forEach((runner) => { + for (const runner of result.data) { const baseValue = isGitBased ? truncateText(runner.branch ?? 'unknown', 12) : 'Production' - table.addRow( runner.id, (runner.state ?? 'unknown').toUpperCase(), @@ -123,40 +118,67 @@ export const agentsList = async (options: AgentListOptions, command: BaseCommand runner.done_at ? formatDuration(runner.created_at, runner.done_at) : formatDuration(runner.created_at), new Date(runner.created_at).toLocaleDateString(), ) - }) - - // Apply colors to the table output - let tableOutput = table.toString() - - // Create unique status mappings to avoid replacement conflicts - const statusReplacements = new Set() - agentRunners.forEach((runner) => { - const status = runner.state ?? 'unknown' - statusReplacements.add(status) - }) - - // Apply color replacements - statusReplacements.forEach((status) => { - const plainStatus = status.toUpperCase() - const coloredStatus = formatStatus(status) - // Use word boundary regex to avoid partial matches - const regex = new RegExp(`\\b${plainStatus}\\b`, 'g') - tableOutput = tableOutput.replace(regex, coloredStatus) - }) - - log(tableOutput) - - log('') - log(chalk.dim(`Total: ${agentRunners.length.toString()} agent task(s)`)) - log('') + } + + log(colorizeStatuses(table.toString(), result.data)) + log() + log( + chalk.dim(formatPaginationFooter(result.data.length, result.total, result.page, result.perPage, result.hasNext)), + ) + log() log(`${chalk.dim('Use')} ${chalk.cyan('netlify agents:show ')} ${chalk.dim('to view details')}`) - return agentRunners + return result.data } catch (error_) { const error = error_ as Error + stopSpinner({ spinner, error: true }) + return logAndThrowError(`Failed to list agent tasks: ${error.message}`) + } +} - stopSpinner({ spinner: listSpinner, error: true }) +const fetchLatestAgentByRunner = async ( + api: ReturnType, + runners: AgentRunner[], +): Promise> => { + const result = new Map() + await Promise.allSettled( + runners.map(async (runner) => { + const sessions = await api.listAgentRunnerSessions(runner.id, { page: 1, per_page: 1 }) + const agent = sessions[0]?.agent_config?.agent + if (agent) result.set(runner.id, agent) + }), + ) + return result +} - return logAndThrowError(`Failed to list agent tasks: ${error.message}`) +const colorizeStatuses = (tableOutput: string, runners: AgentRunner[]): string => { + let output = tableOutput + const statuses = new Set(runners.map((runner) => runner.state ?? 'unknown')) + for (const status of statuses) { + const plain = status.toUpperCase() + const colored = formatStatus(status) + output = output.replace(new RegExp(`\\b${plain}\\b`, 'g'), colored) + } + return output +} + +const formatPaginationFooter = ( + shown: number, + total: number | undefined, + page: number, + perPage: number, + hasNext: boolean, +): string => { + const lines: string[] = [] + if (total != null) { + const start = (page - 1) * perPage + 1 + const end = (page - 1) * perPage + shown + lines.push(`Showing ${start.toString()}-${end.toString()} of ${total.toString()} task(s)`) + } else { + lines.push(`Showing ${shown.toString()} task(s)`) + } + if (hasNext) { + lines.push(`Use --page ${(page + 1).toString()} to see the next page`) } + return lines.join(' • ') } diff --git a/src/commands/agents/agents-open.ts b/src/commands/agents/agents-open.ts new file mode 100644 index 00000000000..538dec2348a --- /dev/null +++ b/src/commands/agents/agents-open.ts @@ -0,0 +1,69 @@ +import type { OptionValues } from 'commander' + +import { chalk, log, logAndThrowError } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import openBrowser from '../../utils/open-browser.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' + +const VALID_TARGETS = ['preview', 'dashboard', 'pr'] as const +type OpenTarget = (typeof VALID_TARGETS)[number] + +interface AgentOpenOptions extends OptionValues { + json?: boolean +} + +export const agentsOpen = async ( + id: string, + targetArg: string | undefined, + _options: AgentOpenOptions, + command: BaseCommand, +) => { + if (!id) return logAndThrowError('Agent task ID is required') + + const target: OpenTarget = (targetArg ?? 'preview') as OpenTarget + if (!VALID_TARGETS.includes(target)) { + return logAndThrowError(`Invalid target "${target}". Choose one of: ${VALID_TARGETS.join(', ')}`) + } + + await command.authenticate() + const { siteInfo } = command.netlify + const api = createAgentsApi(command.netlify) + const dashboardUrl = `https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${id}` + + if (target === 'dashboard') { + return openUrl(dashboardUrl) + } + + const spinner = startSpinner({ text: 'Looking up agent task...' }) + let runner + try { + runner = await api.getAgentRunner(id) + stopSpinner({ spinner }) + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to fetch agent task: ${error.message}`) + } + + if (target === 'pr') { + if (!runner.pr_url) { + log(chalk.yellow('No pull request exists for this agent task.')) + log(`Create one with: ${chalk.cyan(`netlify agents:pr ${id}`)}`) + return + } + return openUrl(runner.pr_url) + } + + const previewUrl = runner.latest_session_deploy_url + if (!previewUrl) { + log(chalk.yellow('No deploy preview available yet — opening dashboard instead.')) + return openUrl(dashboardUrl) + } + return openUrl(previewUrl) +} + +const openUrl = async (url: string): Promise => { + log(`Opening ${chalk.blue(url)}`) + await openBrowser({ url }) +} diff --git a/src/commands/agents/agents-pr.ts b/src/commands/agents/agents-pr.ts new file mode 100644 index 00000000000..ba51e4d35b4 --- /dev/null +++ b/src/commands/agents/agents-pr.ts @@ -0,0 +1,43 @@ +import type { OptionValues } from 'commander' + +import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' + +interface AgentPrOptions extends OptionValues { + json?: boolean +} + +export const agentsPullRequest = async (id: string, options: AgentPrOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + await command.authenticate() + const api = createAgentsApi(command.netlify) + + const spinner = startSpinner({ text: 'Creating pull request...' }) + try { + const runner = await api.agentRunnerPullRequest(id) + stopSpinner({ spinner }) + + if (options.json) { + logJson(runner) + return runner + } + + if (runner.pr_error) { + log(`${chalk.red('✗')} Pull request failed: ${runner.pr_error}`) + return runner + } + + log(`${chalk.green('✓')} Pull request created!`) + log() + if (runner.pr_url) log(` URL: ${chalk.blue(runner.pr_url)}`) + if (runner.pr_branch) log(` Branch: ${chalk.cyan(runner.pr_branch)}`) + if (runner.pr_state) log(` State: ${chalk.cyan(runner.pr_state)}`) + return runner + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to create pull request: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-publish.ts b/src/commands/agents/agents-publish.ts new file mode 100644 index 00000000000..5f6a16b62ea --- /dev/null +++ b/src/commands/agents/agents-publish.ts @@ -0,0 +1,60 @@ +import type { OptionValues } from 'commander' +import inquirer from 'inquirer' + +import { chalk, exit, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' + +interface AgentPublishOptions extends OptionValues { + json?: boolean + yes?: boolean +} + +export const agentsPublish = async (id: string, options: AgentPublishOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + await command.authenticate() + const { siteInfo } = command.netlify + const api = createAgentsApi(command.netlify) + + if (!options.yes && !options.json) { + if (!process.stdout.isTTY) { + return logAndThrowError('Refusing to publish without --yes when stdin is not a TTY') + } + log(chalk.redBright('Warning'), 'You are about to publish agent changes to production.') + log(` Site: ${chalk.bold(siteInfo.name)}`) + log(` Task: ${chalk.bold(id)}`) + log() + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message: `Publish agent task ${id} to production?`, + default: false, + }, + ]) + if (!confirmed) return exit() + } + + const spinner = startSpinner({ text: 'Publishing to production...' }) + try { + const runner = await api.agentRunnerPublishToProduction(id) + stopSpinner({ spinner }) + + if (options.json) { + logJson(runner) + return runner + } + + log(`${chalk.green('✓')} Published agent task to production!`) + log() + log(` Task ID: ${chalk.cyan(runner.id)}`) + if (runner.merge_commit_sha) log(` Commit: ${chalk.cyan(runner.merge_commit_sha)}`) + log(` Browser: ${chalk.blue(`https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${runner.id}`)}`) + return runner + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to publish: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-redeploy.ts b/src/commands/agents/agents-redeploy.ts new file mode 100644 index 00000000000..7f1308467f7 --- /dev/null +++ b/src/commands/agents/agents-redeploy.ts @@ -0,0 +1,61 @@ +import type { OptionValues } from 'commander' + +import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' +import { formatStatus } from './utils.js' + +interface AgentRedeployOptions extends OptionValues { + session?: string + json?: boolean +} + +export const agentsRedeploy = async (id: string, options: AgentRedeployOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + await command.authenticate() + const api = createAgentsApi(command.netlify) + + let sessionId = options.session + if (!sessionId) { + const lookupSpinner = startSpinner({ text: 'Finding latest completed session...' }) + try { + const sessions = await api.listAgentRunnerSessions(id, { page: 1, per_page: 20 }) + stopSpinner({ spinner: lookupSpinner }) + const latestDone = sessions.find((session) => session.state === 'done') + if (!latestDone) { + return logAndThrowError('No completed session found to redeploy. Pass --session to target a specific one.') + } + sessionId = latestDone.id + } catch (error_) { + stopSpinner({ spinner: lookupSpinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to list sessions: ${error.message}`) + } + } + + const spinner = startSpinner({ text: 'Creating redeploy session...' }) + try { + const session = await api.redeployAgentRunnerSession(id, sessionId) + stopSpinner({ spinner }) + + if (options.json) { + logJson(session) + return session + } + + log(`${chalk.green('✓')} Redeploy session created!`) + log() + log(` Task ID: ${chalk.cyan(id)}`) + log(` Session ID: ${chalk.cyan(session.id)}`) + log(` Source Session: ${chalk.dim(sessionId)}`) + log(` Status: ${formatStatus(session.state)}`) + log() + log(`Watch progress: ${chalk.cyan(`netlify agents:show ${id} --watch`)}`) + return session + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to redeploy: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-revert.ts b/src/commands/agents/agents-revert.ts new file mode 100644 index 00000000000..06fdb071d03 --- /dev/null +++ b/src/commands/agents/agents-revert.ts @@ -0,0 +1,55 @@ +import type { OptionValues } from 'commander' +import inquirer from 'inquirer' + +import { chalk, exit, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' + +interface AgentRevertOptions extends OptionValues { + json?: boolean + yes?: boolean + session?: string +} + +export const agentsRevert = async (id: string, options: AgentRevertOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + if (!options.session) return logAndThrowError('--session is required: revert targets a specific session') + await command.authenticate() + const api = createAgentsApi(command.netlify) + + if (!options.yes && !options.json) { + if (!process.stdout.isTTY) { + return logAndThrowError('Refusing to revert without --yes when stdin is not a TTY') + } + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message: `Revert agent task ${id} to session ${options.session}? Sessions after that will be discarded.`, + default: false, + }, + ]) + if (!confirmed) return exit() + } + + const spinner = startSpinner({ text: 'Reverting agent task...' }) + try { + const runner = await api.agentRunnerRevert(id, options.session) + stopSpinner({ spinner }) + + if (options.json) { + logJson(runner) + return runner + } + + log(`${chalk.green('✓')} Agent task reverted!`) + log(` Task ID: ${chalk.cyan(runner.id)}`) + log(` Reverted to session: ${chalk.cyan(options.session)}`) + return runner + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to revert: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-show.ts b/src/commands/agents/agents-show.ts index 859300a158f..5da352f4b24 100644 --- a/src/commands/agents/agents-show.ts +++ b/src/commands/agents/agents-show.ts @@ -1,172 +1,436 @@ import type { OptionValues } from 'commander' -import { chalk, logAndThrowError, log, logJson } from '../../utils/command-helpers.js' +import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' +import { createAgentsApi, type AgentsApi } from './api.js' +import { TERMINAL_AGENT_STATES, TERMINAL_SESSION_STATES } from './constants.js' import type { AgentRunner, AgentRunnerSession } from './types.js' -import { formatDate, formatDuration, formatStatus, getAgentName } from './utils.js' +import { formatDate, formatDuration, formatStatus, formatUsage, getAgentName } from './utils.js' interface AgentShowOptions extends OptionValues { json?: boolean + watch?: boolean + session?: string } -export const agentsShow = async (id: string, options: AgentShowOptions, command: BaseCommand) => { - const { api, site, siteInfo, apiOpts } = command.netlify +const POLL_INTERVAL_MS = 3000 +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] - await command.authenticate() +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - if (!id) { - return logAndThrowError('Agent task ID is required') +class WatchRenderer { + private currentText = '' + private frame = 0 + private spinnerTimer: NodeJS.Timeout | null = null + private active = false + + start(): void { + if (!process.stdout.isTTY) return + this.active = true + this.spinnerTimer = setInterval(() => { + this.frame = (this.frame + 1) % SPINNER_FRAMES.length + this.draw() + }, 80) } - const showSpinner = startSpinner({ text: 'Fetching agent task details...' }) + stop(): void { + if (this.spinnerTimer) clearInterval(this.spinnerTimer) + this.spinnerTimer = null + if (this.active && process.stdout.isTTY) { + process.stdout.write('\r\x1b[K') + } + this.active = false + } - try { - const response = await fetch( - `${apiOpts.scheme ?? 'https'}://${apiOpts.host ?? api.host}/api/v1/agent_runners/${id}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${api.accessToken ?? ''}`, - 'User-Agent': apiOpts.userAgent, - }, - }, - ) + setText(text: string): void { + this.currentText = text + this.draw() + } - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { error?: string } - throw new Error(errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`) + print(line: string): void { + if (this.active && process.stdout.isTTY) { + process.stdout.write(`\r\x1b[K${line}\n`) + this.draw() + } else { + log(line) } + } + + private draw(): void { + if (!this.active || !process.stdout.isTTY) return + process.stdout.write(`\r\x1b[K${chalk.cyan(SPINNER_FRAMES[this.frame])} ${chalk.dim(this.currentText)}`) + } +} - const agentRunner = (await response.json()) as AgentRunner - stopSpinner({ spinner: showSpinner }) +export const agentsShow = async (id: string, options: AgentShowOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + await command.authenticate() + const api = createAgentsApi(command.netlify) + + if (options.session) { + return showSingleSession(api, id, options.session, options, command) + } + if (options.watch) { if (options.json) { - logJson(agentRunner) - return agentRunner + return logAndThrowError('--watch and --json cannot be combined') } + return watchAgentTask(api, id, command) + } - // Display detailed information - log(chalk.bold('Agent Task Details')) - log(``) + return showAgentTask(api, id, options, command) +} - log(chalk.bold('Basic Information:')) - log(` Task ID: ${chalk.cyan(agentRunner.id)}`) - log(` Status: ${formatStatus(agentRunner.state ?? 'unknown')}`) - log(` Site: ${chalk.cyan(siteInfo.name)} (${site.id ?? ''})`) +const showAgentTask = async (api: AgentsApi, id: string, options: AgentShowOptions, command: BaseCommand) => { + const spinner = startSpinner({ text: 'Fetching agent task details...' }) + try { + const [runner, sessions] = await Promise.all([ + api.getAgentRunner(id), + api.listAgentRunnerSessions(id, { page: 1, per_page: 100 }), + ]) + stopSpinner({ spinner }) - if (agentRunner.user) { - log(` Created by: ${agentRunner.user.full_name ?? 'Anonymous'}`) + if (options.json) { + const payload = { ...runner, sessions } + logJson(payload) + return payload } - // Fetch sessions to get agent information - let sessions: AgentRunnerSession[] | undefined - try { - const sessionsResponse = await fetch( - `${apiOpts.scheme ?? 'https'}://${ - apiOpts.host ?? api.host - }/api/v1/agent_runners/${id}/sessions?page=1&per_page=5`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${api.accessToken ?? ''}`, - 'User-Agent': apiOpts.userAgent, - }, - }, - ) + renderAgentTask(runner, sessions, command) + return runner + } catch (error_) { + const error = error_ as Error + stopSpinner({ spinner, error: true }) + return logAndThrowError(`Failed to show agent task: ${error.message}`) + } +} - if (sessionsResponse.ok) { - sessions = (await sessionsResponse.json()) as AgentRunnerSession[] | undefined - } - } catch { - // Sessions fetch failed, but continue without session data - } +const showSingleSession = async ( + api: AgentsApi, + id: string, + sessionId: string, + options: AgentShowOptions, + command: BaseCommand, +) => { + const spinner = startSpinner({ text: 'Fetching session details...' }) + try { + const session = await api.getAgentRunnerSession(id, sessionId) + stopSpinner({ spinner }) - log(``) - log(chalk.bold('Configuration:')) - - // Display agent information from latest session - if (sessions && sessions.length > 0) { - const latestSession = sessions[0] - if (latestSession.agent_config) { - const { agent, model } = latestSession.agent_config - - if (agent) { - log(` Agent: ${chalk.cyan(getAgentName(agent))}`) - } - if (model) { - log(` Model: ${chalk.cyan(model)}`) - } - } + if (options.json) { + logJson(session) + return session } - const isGitBased = Boolean(siteInfo.build_settings?.repo_branch) + renderSessionDetail(session, id, command) + return session + } catch (error_) { + const error = error_ as Error + stopSpinner({ spinner, error: true }) + return logAndThrowError(`Failed to show session: ${error.message}`) + } +} - if (isGitBased) { - log(` Branch: ${chalk.cyan(agentRunner.branch ?? 'unknown')}`) - if (agentRunner.result_branch) { - log(` Result Branch: ${chalk.green(agentRunner.result_branch)}`) - } - } else { - log(` Base: ${chalk.cyan('Latest production deployment')}`) +const renderAgentTask = (runner: AgentRunner, sessions: AgentRunnerSession[], command: BaseCommand) => { + const { siteInfo, site } = command.netlify + + log(chalk.bold('Agent Task Details')) + log() + + log(chalk.bold('Basic Information:')) + log(` Task ID: ${chalk.cyan(runner.id)}`) + log(` Status: ${formatStatus(runner.state ?? 'unknown')}`) + log(` Site: ${chalk.cyan(siteInfo.name)} (${site.id ?? ''})`) + if (runner.user) log(` Created by: ${runner.user.full_name ?? 'Anonymous'}`) + if (runner.contributors && runner.contributors.length > 1) { + log(` Contributors: ${runner.contributors.map((entry) => entry.full_name ?? 'Anonymous').join(', ')}`) + } + + log() + log(chalk.bold('Configuration:')) + const config = sessions[0]?.agent_config + if (config?.agent) log(` Agent: ${chalk.cyan(getAgentName(config.agent))}`) + if (config?.model) log(` Model: ${chalk.cyan(config.model)}`) + + const isGitBased = Boolean(siteInfo.build_settings?.repo_branch) + if (isGitBased) { + log(` Branch: ${chalk.cyan(runner.branch ?? 'unknown')}`) + } else { + log(` Base: ${chalk.cyan('Latest production deployment')}`) + } + + log() + log(chalk.bold('Task:')) + log(` Prompt: ${chalk.dim(runner.title ?? 'No title')}`) + if (runner.current_task) log(` Current Task: ${chalk.yellow(runner.current_task)}`) + + log() + log(chalk.bold('Timeline:')) + log(` Created: ${formatDate(runner.created_at)}`) + log(` Updated: ${formatDate(runner.updated_at)}`) + if (runner.done_at) { + log(` Completed: ${formatDate(runner.done_at)}`) + log(` Duration: ${formatDuration(runner.created_at, runner.done_at)}`) + } else if (runner.state === 'running') { + log(` Running for: ${formatDuration(runner.created_at)}`) + } + + if (sessions.length > 0) { + log() + log(chalk.bold(`Sessions (${sessions.length.toString()}):`)) + for (const [index, session] of sessions.entries()) { + log() + renderSessionInline(session, index + 1, sessions.length) } + } + + if (runner.pr_url || runner.pr_error) { + log() + log(chalk.bold('Pull Request:')) + if (runner.pr_url) log(` URL: ${chalk.blue(runner.pr_url)}`) + if (runner.pr_state) log(` State: ${chalk.cyan(runner.pr_state)}`) + if (runner.pr_error) log(` ${chalk.red('Error:')} ${runner.pr_error}`) + } - log(``) - log(chalk.bold('Task:')) - log(` Prompt: ${chalk.dim(agentRunner.title ?? 'No title')}`) + if (runner.merge_commit_sha || runner.merge_commit_error) { + log() + log(chalk.bold('Branch Commit:')) + if (runner.merge_commit_sha) log(` SHA: ${chalk.cyan(runner.merge_commit_sha)}`) + if (runner.merge_commit_error) log(` ${chalk.red('Error:')} ${runner.merge_commit_error}`) + } - if (agentRunner.current_task) { - log(` Current Task: ${chalk.yellow(agentRunner.current_task)}`) + log() + log(chalk.bold('Actions:')) + if (runner.state === 'running' || runner.state === 'new') { + log(` Stop: ${chalk.cyan(`netlify agents:stop ${runner.id}`)}`) + log(` Watch: ${chalk.cyan(`netlify agents:show ${runner.id} --watch`)}`) + } + if (runner.has_result_diff) { + log(` View diff: ${chalk.cyan(`netlify agents:diff ${runner.id}`)}`) + } + if (runner.latest_session_deploy_url) { + log(` Open preview: ${chalk.cyan(`netlify agents:open ${runner.id}`)}`) + } + log(` View in browser: ${chalk.blue(`https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${runner.id}`)}`) +} + +const renderSessionInline = (session: AgentRunnerSession, index: number, total: number) => { + const header = ` ${index.toString()}/${total.toString()} ${chalk.bold(session.title ?? session.prompt.slice(0, 80))}` + log(header) + const meta: string[] = [formatStatus(session.state)] + if (session.mode && session.mode !== 'normal') meta.push(chalk.dim(`mode: ${session.mode}`)) + if (session.done_at) { + meta.push(chalk.dim(`took ${formatDuration(session.created_at, session.done_at)}`)) + } else if (session.state === 'running') { + meta.push(chalk.dim(`running for ${formatDuration(session.created_at)}`)) + } + log(` ${meta.join(' • ')}`) + log(` ${chalk.dim('id:')} ${session.id}`) + if (session.deploy_url) log(` ${chalk.dim('preview:')} ${chalk.blue(session.deploy_url)}`) + if (session.commit_sha) log(` ${chalk.dim('commit:')} ${chalk.cyan(session.commit_sha)}`) + + if (session.steps && session.steps.length > 0) { + log(` ${chalk.dim('Steps:')}`) + for (const step of session.steps) { + const title = step.title ?? '(untitled step)' + log(` ${chalk.green('✓')} ${title}`) + if (step.message) log(` ${chalk.dim(step.message)}`) } + } - log(``) - log(chalk.bold('Timeline:')) - log(` Created: ${formatDate(agentRunner.created_at)}`) - log(` Updated: ${formatDate(agentRunner.updated_at)}`) + for (const line of formatUsage(session.usage)) { + log(` ${chalk.dim(line)}`) + } - if (agentRunner.done_at) { - log(` Completed: ${formatDate(agentRunner.done_at)}`) - log(` Duration: ${formatDuration(agentRunner.created_at, agentRunner.done_at)}`) - } else if (agentRunner.state === 'running') { - log(` Running for: ${formatDuration(agentRunner.created_at)}`) + if (session.result && session.state === 'done') { + const resultPreview = session.result.length > 200 ? `${session.result.substring(0, 200)}...` : session.result + log(` ${chalk.dim('Result:')} ${chalk.dim(resultPreview)}`) + } +} + +const renderSessionDetail = (session: AgentRunnerSession, runnerId: string, command: BaseCommand) => { + const { siteInfo } = command.netlify + log(chalk.bold('Session Details')) + log() + log(` Session ID: ${chalk.cyan(session.id)}`) + log(` Task ID: ${chalk.cyan(runnerId)}`) + log(` Status: ${formatStatus(session.state)}`) + if (session.mode) log(` Mode: ${chalk.cyan(session.mode)}`) + if (session.agent_config?.agent) log(` Agent: ${chalk.cyan(getAgentName(session.agent_config.agent))}`) + if (session.agent_config?.model) log(` Model: ${chalk.cyan(session.agent_config.model)}`) + + log() + log(chalk.bold('Prompt:')) + log(` ${session.prompt}`) + + log() + log(chalk.bold('Timeline:')) + log(` Created: ${formatDate(session.created_at)}`) + log(` Updated: ${formatDate(session.updated_at)}`) + if (session.done_at) { + log(` Completed: ${formatDate(session.done_at)}`) + log(` Duration: ${formatDuration(session.created_at, session.done_at)}`) + } + + if (session.steps && session.steps.length > 0) { + log() + log(chalk.bold('Steps:')) + for (const step of session.steps) { + log(` ${chalk.green('✓')} ${step.title ?? '(untitled step)'}`) + if (step.message) log(` ${chalk.dim(step.message)}`) } + } + + if (session.deploy_url) { + log() + log(chalk.bold('Deploy:')) + log(` URL: ${chalk.blue(session.deploy_url)}`) + } + + if (session.commit_sha) { + log() + log(chalk.bold('Commit:')) + log(` SHA: ${chalk.cyan(session.commit_sha)}`) + } + + const usage = formatUsage(session.usage) + if (usage.length > 0) { + log() + log(chalk.bold('Usage:')) + for (const line of usage) log(` ${line}`) + } + + if (session.result) { + log() + log(chalk.bold('Result:')) + log(` ${session.result}`) + } - // Show recent runs if available - if (sessions && sessions.length > 0) { - log(``) - log(chalk.bold('Recent Runs:')) - sessions.slice(0, 3).forEach((session, index) => { - log(` ${(index + 1).toString()}. ${formatStatus(session.state)} - ${session.title ?? 'No title'}`) - if (session.result && session.state === 'done') { - const resultPreview = session.result.length > 100 ? session.result.substring(0, 100) + '...' : session.result - log(` ${chalk.dim(resultPreview)}`) - } - }) - - if (sessions.length > 3) { - log(` ${chalk.dim(`... and ${(sessions.length - 3).toString()} more runs`)}`) + log() + log(` View in browser: ${chalk.blue(`https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${runnerId}`)}`) +} + +interface WatchSnapshot { + state?: string + currentTask?: string + sessionStates: Map + sessionIds: string[] + sessionStepCounts: Map +} + +const takeSnapshot = (runner: AgentRunner, sessions: AgentRunnerSession[]): WatchSnapshot => { + const sessionStates = new Map() + const sessionStepCounts = new Map() + for (const session of sessions) { + sessionStates.set(session.id, session.state) + sessionStepCounts.set(session.id, session.steps?.length ?? 0) + } + return { + state: runner.state, + currentTask: runner.current_task, + sessionStates, + sessionIds: sessions.map((session) => session.id), + sessionStepCounts, + } +} + +const watchAgentTask = async (api: AgentsApi, id: string, command: BaseCommand) => { + const renderer = new WatchRenderer() + let previous: WatchSnapshot | null = null + let lastRunner: AgentRunner = await api.getAgentRunner(id) + let lastSessions: AgentRunnerSession[] = await api.listAgentRunnerSessions(id, { page: 1, per_page: 100 }) + + log(`${chalk.cyan('Watching')} agent task ${chalk.bold(id)} ${chalk.dim('(Ctrl+C to stop)')}`) + log() + + renderer.start() + try { + for (;;) { + const events = computeWatchEvents(lastRunner, lastSessions, previous) + for (const event of events) renderer.print(event) + + renderer.setText(describeBottomLine(lastRunner, lastSessions)) + previous = takeSnapshot(lastRunner, lastSessions) + + if (TERMINAL_AGENT_STATES.includes(lastRunner.state as (typeof TERMINAL_AGENT_STATES)[number])) { + break } + await sleep(POLL_INTERVAL_MS) + ;[lastRunner, lastSessions] = await Promise.all([ + api.getAgentRunner(id), + api.listAgentRunnerSessions(id, { page: 1, per_page: 100 }), + ]) } + } finally { + renderer.stop() + } + + log() + renderAgentTask(lastRunner, lastSessions, command) + return lastRunner +} - log(``) - log(chalk.bold('Actions:')) +const describeBottomLine = (runner: AgentRunner, sessions: AgentRunnerSession[]): string => { + const active = sessions.find( + (session) => !TERMINAL_SESSION_STATES.includes(session.state as (typeof TERMINAL_SESSION_STATES)[number]), + ) + if (active) { + const step = active.steps?.[active.steps.length - 1] + const detail = runner.current_task ?? step?.title ?? 'working...' + return `Session ${active.id.slice(-6)}: ${detail}` + } + return `state: ${runner.state ?? 'unknown'}` +} - if (agentRunner.state === 'running' || agentRunner.state === 'new') { - log(` Stop: ${chalk.cyan(`netlify agents:stop ${agentRunner.id}`)}`) +const computeWatchEvents = ( + runner: AgentRunner, + sessions: AgentRunnerSession[], + previous: WatchSnapshot | null, +): string[] => { + const events: string[] = [] + if (!previous) { + events.push(`${chalk.dim('•')} state: ${formatStatus(runner.state ?? 'unknown')}`) + for (const session of sessions) { + events.push(`${chalk.dim('•')} session ${session.id.slice(-6)} ${formatStatus(session.state)}`) } + return events + } - log( - ` View in browser: ${chalk.blue( - `https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${agentRunner.id}`, + if (previous.state !== runner.state) { + events.push( + `${chalk.dim('•')} state: ${formatStatus(previous.state ?? 'unknown')} → ${formatStatus( + runner.state ?? 'unknown', )}`, ) + } - return agentRunner - } catch (error_) { - const error = error_ as Error - - stopSpinner({ spinner: showSpinner, error: true }) - - return logAndThrowError(`Failed to show agent task: ${error.message}`) + const previousIds = new Set(previous.sessionIds) + for (const session of sessions) { + if (!previousIds.has(session.id)) { + events.push(`${chalk.dim('•')} new session ${session.id.slice(-6)} ${formatStatus(session.state)}`) + continue + } + const previousState = previous.sessionStates.get(session.id) + if (previousState && previousState !== session.state) { + const duration = session.done_at ? ` in ${formatDuration(session.created_at, session.done_at)}` : '' + events.push( + `${chalk.dim('•')} session ${session.id.slice(-6)}: ${formatStatus(previousState)} → ${formatStatus( + session.state, + )}${duration}`, + ) + } + const previousStepCount = previous.sessionStepCounts.get(session.id) ?? 0 + const currentStepCount = session.steps?.length ?? 0 + if (currentStepCount > previousStepCount && session.steps) { + for (let stepIndex = previousStepCount; stepIndex < currentStepCount; stepIndex += 1) { + const step = session.steps[stepIndex] + events.push( + `${chalk.green('✓')} ${step.title ?? '(step)'}${step.message ? chalk.dim(` - ${step.message}`) : ''}`, + ) + } + } } + + return events } diff --git a/src/commands/agents/agents-stop.ts b/src/commands/agents/agents-stop.ts index c8f35abb068..0dc0bf298e0 100644 --- a/src/commands/agents/agents-stop.ts +++ b/src/commands/agents/agents-stop.ts @@ -1,107 +1,136 @@ import type { OptionValues } from 'commander' +import inquirer from 'inquirer' -import { chalk, logAndThrowError, log, logJson } from '../../utils/command-helpers.js' +import { chalk, exit, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' -import type { AgentRunner } from './types.js' +import { createAgentsApi } from './api.js' +import { TERMINAL_AGENT_STATES, TERMINAL_SESSION_STATES } from './constants.js' import { formatStatus } from './utils.js' interface AgentStopOptions extends OptionValues { json?: boolean + session?: string + yes?: boolean } export const agentsStop = async (id: string, options: AgentStopOptions, command: BaseCommand) => { - const { api, apiOpts } = command.netlify - + if (!id) return logAndThrowError('Agent task ID is required') await command.authenticate() + const api = createAgentsApi(command.netlify) + + if (options.session) { + return stopSession(api, id, options.session, options) + } + + return stopRunner(api, id, options) +} + +const stopRunner = async (api: ReturnType, id: string, options: AgentStopOptions) => { + const fetchSpinner = startSpinner({ text: 'Checking agent task status...' }) + let runner + try { + runner = await api.getAgentRunner(id) + stopSpinner({ spinner: fetchSpinner }) + } catch (error_) { + stopSpinner({ spinner: fetchSpinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to fetch agent task: ${error.message}`) + } - if (!id) { - return logAndThrowError('Agent task ID is required') + if (runner.state && TERMINAL_AGENT_STATES.includes(runner.state as (typeof TERMINAL_AGENT_STATES)[number])) { + log(chalk.yellow(`Agent task is already ${runner.state}.`)) + return runner } - const statusSpinner = startSpinner({ text: 'Checking agent task status...' }) + if (!options.yes && !options.json) { + const confirmed = await confirmStop(`Stop agent task ${id}?`) + if (!confirmed) return exit() + } + const stopSpin = startSpinner({ text: 'Stopping agent task...' }) try { - // First check if the agent runner exists and is stoppable - const statusResponse = await fetch( - `${apiOpts.scheme ?? 'https'}://${apiOpts.host ?? api.host}/api/v1/agent_runners/${id}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${api.accessToken ?? ''}`, - 'User-Agent': apiOpts.userAgent, - }, - }, - ) - - if (!statusResponse.ok) { - const errorData = (await statusResponse.json().catch(() => ({}))) as { error?: string } - throw new Error(errorData.error ?? `HTTP ${statusResponse.status.toString()}: ${statusResponse.statusText}`) - } - - const agentRunner = (await statusResponse.json()) as AgentRunner - stopSpinner({ spinner: statusSpinner }) - - // Check if agent task can be stopped - if (agentRunner.state === 'done') { - log(chalk.yellow('Agent task is already completed.')) - return agentRunner - } - - if (agentRunner.state === 'cancelled') { - log(chalk.yellow('Agent task is already cancelled.')) - return agentRunner - } - - if (agentRunner.state === 'error') { - log(chalk.yellow('Agent task has already errored.')) - return agentRunner - } - - // Stop the agent task - const stopSpinnerInstance = startSpinner({ text: 'Stopping agent task...' }) - - const response = await fetch( - `${apiOpts.scheme ?? 'https'}://${apiOpts.host ?? api.host}/api/v1/agent_runners/${id}`, - { - method: 'DELETE', - headers: { - Authorization: `Bearer ${api.accessToken ?? ''}`, - 'User-Agent': apiOpts.userAgent, - }, - }, - ) - - stopSpinner({ spinner: stopSpinnerInstance }) - - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { error?: string } - throw new Error(errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`) - } - - // Success case, 202 with empty body - const result = { success: true } - - if (options.json) { - logJson(result) - return result - } - - log(`${chalk.green('✓')} Agent task stopped successfully!`) - log(``) - log(chalk.bold('Details:')) - log(` Task ID: ${chalk.cyan(id)}`) - log(` Previous Status: ${formatStatus(agentRunner.state ?? 'unknown')}`) - log(` New Status: ${formatStatus('cancelled')}`) - log(``) - log(chalk.dim('The agent task has been stopped and will not continue processing.')) + await api.stopAgentRunner(id) + stopSpinner({ spinner: stopSpin }) + } catch (error_) { + stopSpinner({ spinner: stopSpin, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to stop agent task: ${error.message}`) + } + const result = { success: true } + if (options.json) { + logJson(result) return result + } + + log(`${chalk.green('✓')} Agent task stopped successfully!`) + log() + log(chalk.bold('Details:')) + log(` Task ID: ${chalk.cyan(id)}`) + log(` Previous Status: ${formatStatus(runner.state ?? 'unknown')}`) + log(` New Status: ${formatStatus('cancelled')}`) + log() + log(chalk.dim('The agent task has been stopped and will not continue processing.')) + return result +} + +const stopSession = async ( + api: ReturnType, + id: string, + sessionId: string, + options: AgentStopOptions, +) => { + const fetchSpinner = startSpinner({ text: 'Checking session status...' }) + let session + try { + session = await api.getAgentRunnerSession(id, sessionId) + stopSpinner({ spinner: fetchSpinner }) } catch (error_) { + stopSpinner({ spinner: fetchSpinner, error: true }) const error = error_ as Error + return logAndThrowError(`Failed to fetch session: ${error.message}`) + } - stopSpinner({ spinner: statusSpinner, error: true }) + if (TERMINAL_SESSION_STATES.includes(session.state as (typeof TERMINAL_SESSION_STATES)[number])) { + log(chalk.yellow(`Session is already ${session.state}.`)) + return session + } - return logAndThrowError(`Failed to stop agent task: ${error.message}`) + if (!options.yes && !options.json) { + const confirmed = await confirmStop(`Stop session ${sessionId}?`) + if (!confirmed) return exit() + } + + const stopSpin = startSpinner({ text: 'Stopping session...' }) + try { + await api.stopAgentRunnerSession(id, sessionId) + stopSpinner({ spinner: stopSpin }) + } catch (error_) { + stopSpinner({ spinner: stopSpin, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to stop session: ${error.message}`) + } + + const result = { success: true } + if (options.json) { + logJson(result) + return result + } + + log(`${chalk.green('✓')} Session stopped successfully!`) + log() + log(` Session ID: ${chalk.cyan(sessionId)}`) + log(` Previous Status: ${formatStatus(session.state)}`) + return result +} + +const confirmStop = async (message: string): Promise => { + if (!process.stdout.isTTY) { + return logAndThrowError('Refusing to stop without --yes when stdin is not a TTY') } + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { type: 'confirm', name: 'confirmed', message, default: false }, + ]) + return confirmed } diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index b139a133119..765e280cef6 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -4,6 +4,8 @@ import { chalk } from '../../utils/command-helpers.js' import requiresSiteInfoWithProject from '../../utils/hooks/requires-site-info-with-project.js' import type BaseCommand from '../base-command.js' +const collect = (value: string, previous: string[] = []): string[] => [...previous, value] + const agents = (_options: OptionValues, command: BaseCommand) => { command.help() } @@ -18,6 +20,11 @@ export const createAgentsCommand = (program: BaseCommand) => { .option('-a, --agent ', 'agent type (claude, codex, gemini)') .option('-m, --model ', 'model to use for the agent') .option('-b, --branch ', 'git branch to work on') + .option('--from-deploy ', 'start the agent from a specific deploy (mutually exclusive with --branch)') + .option('--parent ', 'chain this agent task off of another agent task') + .option('--mode ', 'session mode (normal, create, ask)') + .option('--dev-server-image ', 'custom dev server Docker image') + .option('--attach ', 'attach a file or image (repeatable)', collect, []) .option('--project ', 'project ID or name (if not in a linked directory)') .option('--json', 'output result as JSON') .hook('preAction', requiresSiteInfoWithProject) @@ -26,21 +33,59 @@ export const createAgentsCommand = (program: BaseCommand) => { 'netlify agents:create "Fix the login bug"', 'netlify agents:create --prompt "Add dark mode" --agent claude', 'netlify agents:create -p "Update README" -a codex -b feature-branch', - 'netlify agents:create "Add tests" --project my-site-name', + 'netlify agents:create "Triage this error" --attach error.log --attach screenshot.png', + 'netlify agents:create "Tell me about this codebase" --mode ask', ]) .action(async (prompt: string, options: OptionValues, command: BaseCommand) => { const { agentsCreate } = await import('./agents-create.js') await agentsCreate(prompt, options, command) }) + program + .command('agents:follow-up') + .argument('', 'agent task ID to follow up on') + .argument('[prompt]', 'the follow-up prompt') + .description('Send a follow-up prompt to an existing agent task') + .option('-p, --prompt ', 'follow-up prompt') + .option('-a, --agent ', 'override agent type for this session') + .option('-m, --model ', 'override model for this session') + .option('--dev-server-image ', 'custom dev server Docker image') + .option('--attach ', 'attach a file or image (repeatable)', collect, []) + .option('--project ', 'project ID or name (if not in a linked directory)') + .option('--json', 'output result as JSON') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples([ + 'netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a "Also add tests"', + 'netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a -p "Fix the lint error"', + ]) + .action(async (id: string, prompt: string, options: OptionValues, command: BaseCommand) => { + const { agentsFollowUp } = await import('./agents-follow-up.js') + await agentsFollowUp(id, prompt, options, command) + }) + program .command('agents:list') .description('List agent tasks for the current site') + .option('-s, --status ', 'filter by status (live, error)') + .option('-b, --branch ', 'filter by branch') + .option('-u, --user ', 'filter by user ID') + .option('-t, --title ', 'filter by title (case-insensitive contains)') + .option('--since ', 'only show tasks created on or after this ISO timestamp') + .option('--until ', 'only show tasks created on or before this ISO timestamp') + .option('--page ', 'page number (1-based)') + .option('--per-page ', 'items per page (max 100)') + .option('--account ', 'list tasks across an account instead of just this site') .option('--json', 'output result as JSON') - .option('-s, --status ', 'filter by status (new, running, done, error, cancelled)') + .option('--ndjson', 'output one JSON object per line') .option('--project ', 'project ID or name (if not in a linked directory)') .hook('preAction', requiresSiteInfoWithProject) - .addExamples(['netlify agents:list', 'netlify agents:list --status running', 'netlify agents:list --json']) + .addExamples([ + 'netlify agents:list', + 'netlify agents:list --status live', + 'netlify agents:list --branch main --since 2026-04-01', + 'netlify agents:list --account my-team', + 'netlify agents:list --ndjson', + ]) .action(async (options: OptionValues, command: BaseCommand) => { const { agentsList } = await import('./agents-list.js') await agentsList(options, command) @@ -50,12 +95,15 @@ export const createAgentsCommand = (program: BaseCommand) => { .command('agents:show') .argument('', 'agent task ID to show') .description('Show details of a specific agent task') + .option('-w, --watch', 'poll until the task reaches a terminal state') + .option('--session ', 'show details of a specific session within the task') .option('--json', 'output result as JSON') .option('--project ', 'project ID or name (if not in a linked directory)') .hook('preAction', requiresSiteInfoWithProject) .addExamples([ 'netlify agents:show 60c7c3b3e7b4a0001f5e4b3a', - 'netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --json', + 'netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch', + 'netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --session 70d8...', ]) .action(async (id: string, options: OptionValues, command: BaseCommand) => { const { agentsShow } = await import('./agents-show.js') @@ -65,16 +113,150 @@ export const createAgentsCommand = (program: BaseCommand) => { program .command('agents:stop') .argument('', 'agent task ID to stop') - .description('Stop a running agent task') + .description('Stop a running agent task or session') + .option('--session ', 'stop a single session instead of the entire task') + .option('-y, --yes', 'skip confirmation prompt') .option('--json', 'output result as JSON') .option('--project ', 'project ID or name (if not in a linked directory)') .hook('preAction', requiresSiteInfoWithProject) - .addExamples(['netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a']) + .addExamples([ + 'netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a --session 70d8... --yes', + ]) .action(async (id: string, options: OptionValues, command: BaseCommand) => { const { agentsStop } = await import('./agents-stop.js') await agentsStop(id, options, command) }) + program + .command('agents:open') + .argument('', 'agent task ID to open') + .argument('[target]', 'what to open: preview (default), dashboard, or pr', 'preview') + .description('Open the agent task preview, dashboard, or pull request in a browser') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples([ + 'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a dashboard', + 'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a pr', + ]) + .action(async (id: string, target: string | undefined, options: OptionValues, command: BaseCommand) => { + const { agentsOpen } = await import('./agents-open.js') + await agentsOpen(id, target, options, command) + }) + + program + .command('agents:diff') + .argument('', 'agent task ID') + .description('Print the unified diff produced by an agent task') + .option('--page ', 'page number (1-based)') + .option('--per-page ', 'files per page (max 100)') + .option('--session ', 'show a single session diff instead of the task aggregate') + .option('--cumulative', 'with --session, show the cumulative diff up through that session') + .option('--no-strip-binary', 'include raw binary content in the diff (off by default)') + .option('--no-color', 'disable color in the output') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples([ + 'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --page 2', + 'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --session 70d8... --cumulative', + 'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --no-color | less', + ]) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsDiff } = await import('./agents-diff.js') + await agentsDiff(id, options, command) + }) + + program + .command('agents:pr') + .argument('', 'agent task ID') + .description('Open a pull request for an agent task') + .option('--json', 'output result as JSON') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples(['netlify agents:pr 60c7c3b3e7b4a0001f5e4b3a']) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsPullRequest } = await import('./agents-pr.js') + await agentsPullRequest(id, options, command) + }) + + program + .command('agents:commit') + .argument('', 'agent task ID') + .description("Commit an agent task's changes directly to a branch") + .option('-b, --branch ', 'target branch to commit to') + .option('--json', 'output result as JSON') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples(['netlify agents:commit 60c7c3b3e7b4a0001f5e4b3a --branch staging']) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsCommit } = await import('./agents-commit.js') + await agentsCommit(id, options, command) + }) + + program + .command('agents:publish') + .argument('', 'agent task ID') + .description("Publish an agent task's changes to production") + .option('-y, --yes', 'skip confirmation prompt') + .option('--json', 'output result as JSON') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples([ + 'netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --yes', + ]) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsPublish } = await import('./agents-publish.js') + await agentsPublish(id, options, command) + }) + + program + .command('agents:revert') + .argument('', 'agent task ID') + .description('Revert an agent task to a specific session (sessions after it are discarded)') + .requiredOption('--session ', 'session ID to revert to') + .option('-y, --yes', 'skip confirmation prompt') + .option('--json', 'output result as JSON') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples(['netlify agents:revert 60c7c3b3e7b4a0001f5e4b3a --session 70d8...']) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsRevert } = await import('./agents-revert.js') + await agentsRevert(id, options, command) + }) + + program + .command('agents:archive') + .argument('', 'agent task ID') + .description('Archive an agent task') + .option('--json', 'output result as JSON') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples(['netlify agents:archive 60c7c3b3e7b4a0001f5e4b3a']) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsArchive } = await import('./agents-archive.js') + await agentsArchive(id, options, command) + }) + + program + .command('agents:redeploy') + .argument('', 'agent task ID') + .description('Create a redeploy session that reapplies an existing diff (no AI inference)') + .option('--session ', 'redeploy a specific session (defaults to the latest completed one)') + .option('--json', 'output result as JSON') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples([ + 'netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a --session 70d8...', + ]) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsRedeploy } = await import('./agents-redeploy.js') + await agentsRedeploy(id, options, command) + }) + const name = chalk.greenBright('`agents`') return program @@ -87,8 +269,11 @@ Note: Agent tasks execute remotely on Netlify infrastructure, not locally.`, ) .addExamples([ 'netlify agents:create --prompt "Add a contact form"', - 'netlify agents:list --status running', - 'netlify agents:show 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:list --status live', + 'netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch', + 'netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a "Also add tests"', + 'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a', ]) .action(agents) } diff --git a/src/commands/agents/api.ts b/src/commands/agents/api.ts new file mode 100644 index 00000000000..305878f2474 --- /dev/null +++ b/src/commands/agents/api.ts @@ -0,0 +1,233 @@ +import type BaseCommand from '../base-command.js' +import { parseLinkHeader } from './utils.js' +import type { + AgentRunner, + AgentRunnerSession, + AiGatewayProvidersResponse, + CreateAgentRunnerPayload, + CreateAgentRunnerSessionPayload, + DeleteUrlResponse, + DiffParams, + ListAgentRunnerSessionsFilters, + ListAgentRunnersFilters, + PaginatedResult, + UploadUrlResponse, +} from './types.js' + +type NetlifyContext = BaseCommand['netlify'] + +const DEFAULT_PER_PAGE = 100 + +type RawResponseHandler = (response: Response) => Promise + +type SearchParamValue = string | number | boolean | null | undefined + +const buildSearchParams = (entries: Record): URLSearchParams => { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(entries)) { + if (value === undefined || value === null || value === '') continue + params.set(key, value.toString()) + } + return params +} + +const readPagination = (response: Response, page: number, perPage: number): { total?: number; hasNext: boolean } => { + const totalHeader = response.headers.get('Total') + const total = totalHeader != null ? Number.parseInt(totalHeader, 10) : undefined + const links = parseLinkHeader(response.headers.get('Link')) + const hasNext = Boolean(links.next) || (total != null && page * perPage < total) + return { total: Number.isFinite(total) ? total : undefined, hasNext } +} + +export const createAgentsApi = (netlify: NetlifyContext) => { + const { api, apiOpts } = netlify + const baseUrl = `${apiOpts.scheme ?? 'https'}://${apiOpts.host ?? api.host}/api/v1` + + const baseHeaders = (extra: Record = {}): Record => ({ + Authorization: `Bearer ${api.accessToken ?? ''}`, + 'User-Agent': apiOpts.userAgent, + ...extra, + }) + + const throwForStatus = async (response: Response): Promise => { + const errorData = (await response.json().catch(() => ({}))) as { error?: string } + throw new Error(errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`) + } + + const requestRaw = async (path: string, init: RequestInit, handler: RawResponseHandler): Promise => { + const response = await fetch(`${baseUrl}${path}`, init) + if (!response.ok) await throwForStatus(response) + return handler(response) + } + + const requestJson = async (path: string, init: RequestInit = {}): Promise => + requestRaw(path, init, async (response) => { + if (response.status === 202) return undefined as T + const text = await response.text() + if (!text) return undefined as T + return JSON.parse(text) as T + }) + + const requestNoContent = (path: string, init: RequestInit = {}): Promise => + requestRaw(path, init, () => Promise.resolve(undefined)) + + const jsonInit = (method: string, body?: unknown): RequestInit => ({ + method, + headers: baseHeaders(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + + const getInit = (): RequestInit => ({ method: 'GET', headers: baseHeaders() }) + + const listAgentRunners = async ( + siteId: string, + filters: ListAgentRunnersFilters = {}, + ): Promise> => { + const page = filters.page ?? 1 + const perPage = filters.per_page ?? DEFAULT_PER_PAGE + const params = buildSearchParams({ ...filters, site_id: siteId, page, per_page: perPage }) + const response = await fetch(`${baseUrl}/agent_runners?${params.toString()}`, getInit()) + if (!response.ok) await throwForStatus(response) + const data = (await response.json()) as AgentRunner[] + const { total, hasNext } = readPagination(response, page, perPage) + return { data, total, page, perPage, hasNext } + } + + const listAgentRunnersForAccount = async ( + accountSlug: string, + filters: ListAgentRunnersFilters = {}, + ): Promise> => { + const page = filters.page ?? 1 + const perPage = filters.per_page ?? DEFAULT_PER_PAGE + const params = buildSearchParams({ ...filters, page, per_page: perPage }) + const response = await fetch( + `${baseUrl}/${encodeURIComponent(accountSlug)}/agent_runners?${params.toString()}`, + getInit(), + ) + if (!response.ok) await throwForStatus(response) + const data = (await response.json()) as AgentRunner[] + const { total, hasNext } = readPagination(response, page, perPage) + return { data, total, page, perPage, hasNext } + } + + const getAgentRunner = (id: string): Promise => + requestJson(`/agent_runners/${id}`, getInit()) + + const createAgentRunner = (siteId: string, payload: CreateAgentRunnerPayload): Promise => { + const params = buildSearchParams({ site_id: siteId }) + return requestJson(`/agent_runners?${params.toString()}`, jsonInit('POST', payload)) + } + + const stopAgentRunner = (id: string): Promise => + requestNoContent(`/agent_runners/${id}`, { method: 'DELETE', headers: baseHeaders() }) + + const archiveAgentRunner = (id: string): Promise => + requestNoContent(`/agent_runners/${id}/archive`, { method: 'POST', headers: baseHeaders() }) + + const listAgentRunnerSessions = async ( + id: string, + filters: ListAgentRunnerSessionsFilters = {}, + ): Promise => { + const page = filters.page ?? 1 + const perPage = filters.per_page ?? 5 + const params = buildSearchParams({ ...filters, page, per_page: perPage }) + return requestJson(`/agent_runners/${id}/sessions?${params.toString()}`, getInit()) + } + + const getAgentRunnerSession = (id: string, sessionId: string): Promise => + requestJson(`/agent_runners/${id}/sessions/${sessionId}`, getInit()) + + const createAgentRunnerSession = ( + id: string, + payload: CreateAgentRunnerSessionPayload, + ): Promise => + requestJson(`/agent_runners/${id}/sessions`, jsonInit('POST', payload)) + + const stopAgentRunnerSession = (id: string, sessionId: string): Promise => + requestNoContent(`/agent_runners/${id}/sessions/${sessionId}`, { + method: 'DELETE', + headers: baseHeaders(), + }) + + const redeployAgentRunnerSession = (id: string, sessionId: string): Promise => + requestJson(`/agent_runners/${id}/sessions/${sessionId}/redeploy`, jsonInit('POST')) + + const getAgentRunnerDiff = async (id: string, params: DiffParams = {}): Promise> => { + const page = params.page ?? 1 + const perPage = params.per_page ?? DEFAULT_PER_PAGE + const stripBinary = params.strip_binary ?? true + const search = buildSearchParams({ page, per_page: perPage, strip_binary: stripBinary }) + const response = await fetch(`${baseUrl}/agent_runners/${id}/diff?${search.toString()}`, getInit()) + if (!response.ok) { + if (response.status === 404) return { data: '', total: 0, page, perPage, hasNext: false } + await throwForStatus(response) + } + const body = await response.text() + const { total, hasNext } = readPagination(response, page, perPage) + return { data: body, total, page, perPage, hasNext } + } + + const getSessionDiff = async (id: string, sessionId: string, kind: 'result' | 'cumulative'): Promise => { + const response = await fetch(`${baseUrl}/agent_runners/${id}/sessions/${sessionId}/diff/${kind}`, getInit()) + if (response.status === 404) return '' + if (!response.ok) await throwForStatus(response) + return response.text() + } + + const agentRunnerPullRequest = (id: string): Promise => + requestJson(`/agent_runners/${id}/pull_request`, jsonInit('POST')) + + const agentRunnerCommitToBranch = (id: string, targetBranch: string): Promise => + requestJson(`/agent_runners/${id}/commit`, jsonInit('POST', { target_branch: targetBranch })) + + const agentRunnerPublishToProduction = (id: string): Promise => + requestJson(`/agent_runners/${id}/publish_to_production`, jsonInit('POST')) + + const agentRunnerRevert = (id: string, sessionId: string): Promise => + requestJson(`/agent_runners/${id}/revert`, jsonInit('POST', { session_id: sessionId })) + + const createUploadUrl = (payload: { + account_id: string + filename: string + content_type: string + }): Promise => + requestJson(`/agent_runners/upload_url`, jsonInit('POST', payload)) + + const createDeleteUrl = (payload: { account_id: string; file_key: string }): Promise => + requestJson(`/agent_runners/delete_url`, jsonInit('POST', payload)) + + let providersCache: AiGatewayProvidersResponse | null = null + const listAiGatewayProviders = async (): Promise => { + if (providersCache) return providersCache + const response = await fetch(`${baseUrl}/ai-gateway/providers`) + if (!response.ok) await throwForStatus(response) + providersCache = (await response.json()) as AiGatewayProvidersResponse + return providersCache + } + + return { + listAgentRunners, + listAgentRunnersForAccount, + getAgentRunner, + createAgentRunner, + stopAgentRunner, + archiveAgentRunner, + listAgentRunnerSessions, + getAgentRunnerSession, + createAgentRunnerSession, + stopAgentRunnerSession, + redeployAgentRunnerSession, + getAgentRunnerDiff, + getSessionResultDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'result'), + getSessionCumulativeDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'cumulative'), + agentRunnerPullRequest, + agentRunnerCommitToBranch, + agentRunnerPublishToProduction, + agentRunnerRevert, + createUploadUrl, + createDeleteUrl, + listAiGatewayProviders, + } +} + +export type AgentsApi = ReturnType diff --git a/src/commands/agents/attachments.ts b/src/commands/agents/attachments.ts new file mode 100644 index 00000000000..76551ecbbcf --- /dev/null +++ b/src/commands/agents/attachments.ts @@ -0,0 +1,64 @@ +import fs from 'fs/promises' +import path from 'path' + +import type { AgentsApi } from './api.js' +import { MAX_ATTACHMENT_SIZE_BYTES } from './constants.js' +import { formatBytes, getMimeType } from './utils.js' + +export interface UploadedAttachment { + path: string + filename: string + fileKey: string + size: number + contentType: string +} + +export const uploadAttachments = async ( + api: AgentsApi, + accountId: string, + filePaths: string[], +): Promise => { + if (filePaths.length === 0) return [] + + const resolved = await Promise.all( + filePaths.map(async (filePath) => { + const absolute = path.resolve(filePath) + const stat = await fs.stat(absolute).catch(() => null) + if (!stat?.isFile()) { + throw new Error(`Attachment not found or not a file: ${filePath}`) + } + if (stat.size > MAX_ATTACHMENT_SIZE_BYTES) { + throw new Error( + `Attachment ${filePath} is ${formatBytes(stat.size)}, exceeds the ${formatBytes( + MAX_ATTACHMENT_SIZE_BYTES, + )} limit`, + ) + } + const filename = path.basename(absolute) + return { path: absolute, filename, size: stat.size, contentType: getMimeType(filename) } + }), + ) + + const uploaded: UploadedAttachment[] = [] + for (const file of resolved) { + const { upload_url: uploadUrl, file_key: fileKey } = await api.createUploadUrl({ + account_id: accountId, + filename: file.filename, + content_type: file.contentType, + }) + + const body = await fs.readFile(file.path) + const putResponse = await fetch(uploadUrl, { + method: 'PUT', + body: new Uint8Array(body), + headers: { 'Content-Type': file.contentType }, + }) + if (!putResponse.ok) { + throw new Error( + `Failed to upload ${file.filename}: HTTP ${putResponse.status.toString()} ${putResponse.statusText}`, + ) + } + uploaded.push({ ...file, fileKey }) + } + return uploaded +} diff --git a/src/commands/agents/constants.ts b/src/commands/agents/constants.ts index 03b5f94b287..32240da3fed 100644 --- a/src/commands/agents/constants.ts +++ b/src/commands/agents/constants.ts @@ -1,27 +1,31 @@ import { chalk } from '../../utils/command-helpers.js' -/** - * Available agent types for task creation - */ export const AVAILABLE_AGENTS = [ { name: 'Claude', value: 'claude' }, { name: 'Codex', value: 'codex' }, { name: 'Gemini', value: 'gemini' }, ] as const -/** - * Valid agent task states - */ -export const AGENT_STATES = ['new', 'running', 'done', 'error', 'cancelled', 'archived'] as const +export const AGENT_TO_PROVIDER = { + claude: 'anthropic', + codex: 'openai', + gemini: 'gemini', +} as const -/** - * Valid agent session states - */ +export const AGENT_STATES = ['new', 'running', 'done', 'error', 'cancelled', 'archived'] as const export const SESSION_STATES = ['new', 'running', 'done', 'error', 'cancelled'] as const -/** - * Color mapping for agent task status display - */ +export const SESSION_MODES = [ + 'normal', + 'redeploy', + 'rebase', + 'git_sync', + 'create', + 'ask', + 'conflict_resolution', +] as const +export const USER_SELECTABLE_MODES = ['normal', 'create', 'ask'] as const + export const STATUS_COLORS = { new: chalk.blue, running: chalk.yellow, @@ -31,9 +35,13 @@ export const STATUS_COLORS = { archived: chalk.dim, } as const -/** - * Type definitions extracted from constants - */ +export const TERMINAL_AGENT_STATES = ['done', 'error', 'cancelled', 'archived'] as const +export const TERMINAL_SESSION_STATES = ['done', 'error', 'cancelled'] as const + +export const MAX_ATTACHMENT_SIZE_BYTES = 10 * 1024 * 1024 + export type AgentState = (typeof AGENT_STATES)[number] export type SessionState = (typeof SESSION_STATES)[number] +export type SessionMode = (typeof SESSION_MODES)[number] +export type UserSelectableMode = (typeof USER_SELECTABLE_MODES)[number] export type AvailableAgent = (typeof AVAILABLE_AGENTS)[number]['value'] diff --git a/src/commands/agents/types.ts b/src/commands/agents/types.ts index 345b87101f3..c7276a3df12 100644 --- a/src/commands/agents/types.ts +++ b/src/commands/agents/types.ts @@ -1,4 +1,4 @@ -import type { AgentState, SessionState, AvailableAgent } from './constants.js' +import type { AgentState, SessionState, SessionMode, AvailableAgent } from './constants.js' export interface AgentConfig { agent?: AvailableAgent @@ -6,6 +6,13 @@ export interface AgentConfig { [key: string]: unknown } +export interface AgentRunnerUser { + id: string + full_name?: string + email?: string + avatar_url?: string +} + export interface AgentRunner { id: string site_id?: string @@ -18,11 +25,57 @@ export interface AgentRunner { branch?: string result_branch?: string current_task?: string + base_deploy_id?: string + sha?: string + + pr_url?: string + pr_branch?: string + pr_state?: string + pr_number?: number + pr_is_being_created?: boolean + pr_error?: string + + merge_commit_sha?: string + merge_commit_error?: string + merge_commit_is_being_created?: boolean + + attached_file_keys?: string[] + active_session_created_at?: string + last_session_created_at?: string + has_result_diff?: boolean + latest_session_deploy_id?: string - user?: { - id: string - full_name?: string - } + latest_session_deploy_url?: string + latest_session_deploy_screenshot_url?: string + latest_session_state?: SessionState + latest_session_mode?: SessionMode + latest_session_is_published?: boolean + + needs_git_sync?: boolean + rebase_available?: boolean + merge_target_available?: boolean + + user?: AgentRunnerUser + contributors?: AgentRunnerUser[] +} + +export interface AgentRunnerSessionUsage { + total_input_tokens?: number + total_output_tokens?: number + total_cached_input_tokens?: number + total_cached_output_tokens?: number + total_tokens?: number + total_input_microcents?: number + total_output_microcents?: number + total_cached_input_microcents?: number + total_cached_output_microcents?: number + total_tool_calls_microcents?: number + total_credits_cost?: number +} + +export interface AgentRunnerSessionStep { + title?: string + message?: string } export interface AgentRunnerSession { @@ -30,6 +83,7 @@ export interface AgentRunnerSession { agent_runner_id: string dev_server_id?: string state: SessionState + mode?: SessionMode created_at: string updated_at: string done_at?: string @@ -38,11 +92,87 @@ export interface AgentRunnerSession { agent_config?: AgentConfig result?: string result_diff?: string + cumulative_diff?: string duration?: number - steps?: { - title?: string - message?: string - }[] + steps?: AgentRunnerSessionStep[] + user?: AgentRunnerUser + attached_file_keys?: string[] + result_zip_file_name?: string + is_published?: boolean + is_discarded?: boolean + commit_sha?: string + source_session_id?: string + deploy_id?: string + deploy_url?: string + usage?: AgentRunnerSessionUsage + credit_limit_exceeded?: boolean + metadata?: Record +} + +export interface CreateAgentRunnerPayload { + prompt: string + agent: AvailableAgent + model?: string + branch?: string + deploy_id?: string + parent_agent_runner_id?: string + mode?: SessionMode + dev_server_image?: string + file_keys?: string[] +} + +export interface CreateAgentRunnerSessionPayload { + prompt: string + agent?: AvailableAgent + model?: string + dev_server_image?: string + file_keys?: string[] +} + +export interface ListAgentRunnersFilters { + state?: 'live' | 'error' + branch?: string + result_branch?: string + user_id?: string + title?: string + from?: number + to?: number + page?: number + per_page?: number +} + +export interface ListAgentRunnerSessionsFilters { + state?: SessionState + from?: number + to?: number + order_by?: 'asc' | 'desc' + include_discarded?: boolean + page?: number + per_page?: number +} + +export interface DiffParams { + page?: number + per_page?: number + strip_binary?: boolean +} + +export interface PaginatedResult { + data: T + total?: number + page: number + perPage: number + hasNext: boolean +} + +export interface UploadUrlResponse { + upload_url: string + file_key: string +} + +export interface DeleteUrlResponse { + delete_url: string + file_key: string } export interface APIError { @@ -50,3 +180,13 @@ export interface APIError { message: string error?: string } + +export interface AiGatewayProviderInfo { + token_env_var: string + url_env_var: string + models: string[] +} + +export interface AiGatewayProvidersResponse { + providers: Partial> +} diff --git a/src/commands/agents/utils.ts b/src/commands/agents/utils.ts index 308fb6fb717..97e874b692b 100644 --- a/src/commands/agents/utils.ts +++ b/src/commands/agents/utils.ts @@ -1,5 +1,10 @@ -import { AVAILABLE_AGENTS, STATUS_COLORS } from './constants.js' +import path from 'path' + import { chalk } from '../../utils/command-helpers.js' +import { AGENT_TO_PROVIDER, AVAILABLE_AGENTS, STATUS_COLORS, USER_SELECTABLE_MODES } from './constants.js' +import type { AgentsApi } from './api.js' +import type { AvailableAgent } from './constants.js' +import type { AgentRunnerSessionUsage } from './types.js' export const truncateText = (text: string, maxLength: number): string => { if (text.length <= maxLength) return text @@ -7,8 +12,7 @@ export const truncateText = (text: string, maxLength: number): string => { } export const formatDate = (dateString: string): string => { - const date = new Date(dateString) - return date.toLocaleString() + return new Date(dateString).toLocaleString() } export const formatDuration = (startTime: string, endTime?: string): string => { @@ -34,7 +38,7 @@ export const formatStatus = (status: string): string => { return colorFn(status.toUpperCase()) } -export const validatePrompt = (input: string): boolean | string => { +export const validatePrompt = (input: string): true | string => { if (!input || input.trim().length === 0) { return 'Please provide a prompt for the agent' } @@ -44,15 +48,131 @@ export const validatePrompt = (input: string): boolean | string => { return true } -export const validateAgent = (agent: string): boolean | string => { - const validAgents = AVAILABLE_AGENTS.map((a) => a.value) as string[] +export const validateAgent = (agent: string): true | string => { + const validAgents = AVAILABLE_AGENTS.map((entry) => entry.value) as string[] if (!validAgents.includes(agent)) { return `Invalid agent. Available agents: ${validAgents.join(', ')}` } return true } +export const validateMode = (mode: string): true | string => { + if ((USER_SELECTABLE_MODES as readonly string[]).includes(mode)) return true + return `Invalid mode. Available modes: ${USER_SELECTABLE_MODES.join(', ')}` +} + +export const checkModelAvailability = async ( + api: AgentsApi, + agent: AvailableAgent, + model: string, +): Promise => { + let providers + try { + providers = await api.listAiGatewayProviders() + } catch { + return true + } + const providerName = AGENT_TO_PROVIDER[agent] + const models = providers.providers[providerName]?.models + if (!models) return true + if (models.includes(model)) return true + return `Unknown model "${model}" for agent "${agent}". Known ${providerName} models: ${models.join( + ', ', + )}. Pass through if a newer one has rolled out.` +} + export const getAgentName = (agent: string): string => { - const entry = AVAILABLE_AGENTS.find((a) => a.value === agent) + const entry = AVAILABLE_AGENTS.find((candidate) => candidate.value === agent) return entry ? entry.name : agent } + +export const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes.toString()} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` +} + +export const formatTokenCount = (count?: number): string => { + if (count == null) return '-' + if (count < 1000) return count.toString() + if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k` + return `${(count / 1_000_000).toFixed(2)}M` +} + +export const formatUsage = (usage?: AgentRunnerSessionUsage): string[] => { + if (!usage) return [] + const lines: string[] = [] + const tokens = usage.total_tokens + if (tokens != null) { + const breakdown = [ + usage.total_input_tokens != null ? `in ${formatTokenCount(usage.total_input_tokens)}` : null, + usage.total_output_tokens != null ? `out ${formatTokenCount(usage.total_output_tokens)}` : null, + usage.total_cached_input_tokens || usage.total_cached_output_tokens + ? `cached ${formatTokenCount((usage.total_cached_input_tokens ?? 0) + (usage.total_cached_output_tokens ?? 0))}` + : null, + ].filter(Boolean) + lines.push(`Tokens: ${formatTokenCount(tokens)}${breakdown.length > 0 ? ` (${breakdown.join(', ')})` : ''}`) + } + if (usage.total_credits_cost != null) { + lines.push(`Credits: ${usage.total_credits_cost.toFixed(4)}`) + } + return lines +} + +const MIME_BY_EXT: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.md': 'text/markdown', + '.log': 'text/plain', + '.json': 'application/json', + '.yaml': 'application/yaml', + '.yml': 'application/yaml', + '.toml': 'application/toml', + '.csv': 'text/csv', + '.html': 'text/html', + '.htm': 'text/html', + '.xml': 'application/xml', + '.zip': 'application/zip', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.jsx': 'text/javascript', + '.css': 'text/css', +} + +export const getMimeType = (filename: string): string => { + const ext = path.extname(filename).toLowerCase() + return MIME_BY_EXT[ext] ?? 'application/octet-stream' +} + +export const formatDiff = (diff: string): string => { + if (!diff) return '' + const lines = diff.split('\n') + return lines + .map((line) => { + if (line.startsWith('diff --git') || line.startsWith('index ')) return chalk.bold(line) + if (line.startsWith('--- ') || line.startsWith('+++ ')) return chalk.bold(line) + if (line.startsWith('@@')) return chalk.cyan(line) + if (line.startsWith('+')) return chalk.green(line) + if (line.startsWith('-')) return chalk.red(line) + return line + }) + .join('\n') +} + +export const parseLinkHeader = (linkHeader: string | null): Record => { + if (!linkHeader) return {} + const result: Record = {} + for (const part of linkHeader.split(',')) { + const match = /<([^>]+)>;\s*rel="([^"]+)"/.exec(part.trim()) + if (match) result[match[2]] = match[1] + } + return result +} From e9dcb9a13b19bb04f698861eb3fae83d0507fc4c Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 01:48:19 +0530 Subject: [PATCH 02/18] docs(agents): regenerate command docs for new subcommands --- docs/commands/agents.md | 338 ++++++++++++++++++++++++++++++++++++++-- docs/index.md | 11 +- 2 files changed, 339 insertions(+), 10 deletions(-) diff --git a/docs/commands/agents.md b/docs/commands/agents.md index 1f6cafdee62..bdac39cb5a6 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -27,18 +27,89 @@ netlify agents | Subcommand | description | |:--------------------------- |:-----| +| [`agents:archive`](/commands/agents#agentsarchive) | Archive an agent task | +| [`agents:commit`](/commands/agents#agentscommit) | Commit an agent task's changes directly to a branch | | [`agents:create`](/commands/agents#agentscreate) | Create and run a new agent task on your site | +| [`agents:diff`](/commands/agents#agentsdiff) | Print the unified diff produced by an agent task | +| [`agents:follow-up`](/commands/agents#agentsfollow-up) | Send a follow-up prompt to an existing agent task | | [`agents:list`](/commands/agents#agentslist) | List agent tasks for the current site | +| [`agents:open`](/commands/agents#agentsopen) | Open the agent task preview, dashboard, or pull request in a browser | +| [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent task | +| [`agents:publish`](/commands/agents#agentspublish) | Publish an agent task's changes to production | +| [`agents:redeploy`](/commands/agents#agentsredeploy) | Create a redeploy session that reapplies an existing diff (no AI inference) | +| [`agents:revert`](/commands/agents#agentsrevert) | Revert an agent task to a specific session (sessions after it are discarded) | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent task | -| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent task | +| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent task or session | **Examples** ```bash netlify agents:create --prompt "Add a contact form" -netlify agents:list --status running -netlify agents:show 60c7c3b3e7b4a0001f5e4b3a +netlify agents:list --status live +netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch +netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a "Also add tests" +netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a +netlify agents:open 60c7c3b3e7b4a0001f5e4b3a +``` + +--- +## `agents:archive` + +Archive an agent task + +**Usage** + +```bash +netlify agents:archive +``` + +**Arguments** + +- id - agent task ID + +**Flags** + +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - output result as JSON +- `project` (*string*) - project ID or name (if not in a linked directory) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:archive 60c7c3b3e7b4a0001f5e4b3a +``` + +--- +## `agents:commit` + +Commit an agent task's changes directly to a branch + +**Usage** + +```bash +netlify agents:commit +``` + +**Arguments** + +- id - agent task ID + +**Flags** + +- `branch` (*string*) - target branch to commit to +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - output result as JSON +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `project` (*string*) - project ID or name (if not in a linked directory) + +**Examples** + +```bash +netlify agents:commit 60c7c3b3e7b4a0001f5e4b3a --branch staging ``` --- @@ -59,10 +130,15 @@ netlify agents:create **Flags** - `agent` (*string*) - agent type (claude, codex, gemini) +- `attach` (*string*) - attach a file or image (repeatable) - `branch` (*string*) - git branch to work on +- `dev-server-image` (*string*) - custom dev server Docker image - `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `from-deploy` (*string*) - start the agent from a specific deploy (mutually exclusive with --branch) - `json` (*boolean*) - output result as JSON +- `mode` (*string*) - session mode (normal, create, ask) - `model` (*string*) - model to use for the agent +- `parent` (*string*) - chain this agent task off of another agent task - `project` (*string*) - project ID or name (if not in a linked directory) - `prompt` (*string*) - agent prompt - `debug` (*boolean*) - Print debugging information @@ -75,7 +151,81 @@ netlify agents:create netlify agents:create "Fix the login bug" netlify agents:create --prompt "Add dark mode" --agent claude netlify agents:create -p "Update README" -a codex -b feature-branch -netlify agents:create "Add tests" --project my-site-name +netlify agents:create "Triage this error" --attach error.log --attach screenshot.png +netlify agents:create "Tell me about this codebase" --mode ask +``` + +--- +## `agents:diff` + +Print the unified diff produced by an agent task + +**Usage** + +```bash +netlify agents:diff +``` + +**Arguments** + +- id - agent task ID + +**Flags** + +- `cumulative` (*boolean*) - with --session, show the cumulative diff up through that session +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `no-color` (*boolean*) - disable color in the output +- `no-strip-binary` (*boolean*) - include raw binary content in the diff (off by default) +- `page` (*string*) - page number (1-based) +- `per-page` (*string*) - files per page (max 100) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `project` (*string*) - project ID or name (if not in a linked directory) +- `session` (*string*) - show a single session diff instead of the task aggregate + +**Examples** + +```bash +netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a +netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --page 2 +netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --session 70d8... --cumulative +netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --no-color | less +``` + +--- +## `agents:follow-up` + +Send a follow-up prompt to an existing agent task + +**Usage** + +```bash +netlify agents:follow-up +``` + +**Arguments** + +- id - agent task ID to follow up on +- prompt - the follow-up prompt + +**Flags** + +- `agent` (*string*) - override agent type for this session +- `attach` (*string*) - attach a file or image (repeatable) +- `dev-server-image` (*string*) - custom dev server Docker image +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - output result as JSON +- `model` (*string*) - override model for this session +- `project` (*string*) - project ID or name (if not in a linked directory) +- `prompt` (*string*) - follow-up prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a "Also add tests" +netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a -p "Fix the lint error" ``` --- @@ -91,19 +241,183 @@ netlify agents:list **Flags** +- `account` (*string*) - list tasks across an account instead of just this site +- `branch` (*string*) - filter by branch - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON +- `ndjson` (*boolean*) - output one JSON object per line +- `page` (*string*) - page number (1-based) +- `per-page` (*string*) - items per page (max 100) - `project` (*string*) - project ID or name (if not in a linked directory) -- `status` (*string*) - filter by status (new, running, done, error, cancelled) +- `since` (*string*) - only show tasks created on or after this ISO timestamp +- `status` (*string*) - filter by status (live, error) +- `title` (*string*) - filter by title (case-insensitive contains) - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `until` (*string*) - only show tasks created on or before this ISO timestamp +- `user` (*string*) - filter by user ID **Examples** ```bash netlify agents:list -netlify agents:list --status running -netlify agents:list --json +netlify agents:list --status live +netlify agents:list --branch main --since 2026-04-01 +netlify agents:list --account my-team +netlify agents:list --ndjson +``` + +--- +## `agents:open` + +Open the agent task preview, dashboard, or pull request in a browser + +**Usage** + +```bash +netlify agents:open +``` + +**Arguments** + +- id - agent task ID to open +- target - what to open: preview (default), dashboard, or pr + +**Flags** + +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `project` (*string*) - project ID or name (if not in a linked directory) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:open 60c7c3b3e7b4a0001f5e4b3a +netlify agents:open 60c7c3b3e7b4a0001f5e4b3a dashboard +netlify agents:open 60c7c3b3e7b4a0001f5e4b3a pr +``` + +--- +## `agents:pr` + +Open a pull request for an agent task + +**Usage** + +```bash +netlify agents:pr +``` + +**Arguments** + +- id - agent task ID + +**Flags** + +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - output result as JSON +- `project` (*string*) - project ID or name (if not in a linked directory) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:pr 60c7c3b3e7b4a0001f5e4b3a +``` + +--- +## `agents:publish` + +Publish an agent task's changes to production + +**Usage** + +```bash +netlify agents:publish +``` + +**Arguments** + +- id - agent task ID + +**Flags** + +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - output result as JSON +- `project` (*string*) - project ID or name (if not in a linked directory) +- `yes` (*boolean*) - skip confirmation prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a +netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --yes +``` + +--- +## `agents:redeploy` + +Create a redeploy session that reapplies an existing diff (no AI inference) + +**Usage** + +```bash +netlify agents:redeploy +``` + +**Arguments** + +- id - agent task ID + +**Flags** + +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - output result as JSON +- `project` (*string*) - project ID or name (if not in a linked directory) +- `session` (*string*) - redeploy a specific session (defaults to the latest completed one) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a +netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a --session 70d8... +``` + +--- +## `agents:revert` + +Revert an agent task to a specific session (sessions after it are discarded) + +**Usage** + +```bash +netlify agents:revert +``` + +**Arguments** + +- id - agent task ID + +**Flags** + +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - output result as JSON +- `project` (*string*) - project ID or name (if not in a linked directory) +- `session` (*string*) - session ID to revert to +- `yes` (*boolean*) - skip confirmation prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:revert 60c7c3b3e7b4a0001f5e4b3a --session 70d8... ``` --- @@ -126,6 +440,8 @@ netlify agents:show - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) +- `session` (*string*) - show details of a specific session within the task +- `watch` (*boolean*) - poll until the task reaches a terminal state - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in @@ -133,13 +449,14 @@ netlify agents:show ```bash netlify agents:show 60c7c3b3e7b4a0001f5e4b3a -netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --json +netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch +netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --session 70d8... ``` --- ## `agents:stop` -Stop a running agent task +Stop a running agent task or session **Usage** @@ -156,6 +473,8 @@ netlify agents:stop - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) +- `session` (*string*) - stop a single session instead of the entire task +- `yes` (*boolean*) - skip confirmation prompt - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in @@ -163,6 +482,7 @@ netlify agents:stop ```bash netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a +netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a --session 70d8... --yes ``` --- diff --git a/docs/index.md b/docs/index.md index 2a0899110d5..ec199aa2438 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,10 +24,19 @@ Manage Netlify AI agent tasks | Subcommand | description | |:--------------------------- |:-----| +| [`agents:archive`](/commands/agents#agentsarchive) | Archive an agent task | +| [`agents:commit`](/commands/agents#agentscommit) | Commit an agent task's changes directly to a branch | | [`agents:create`](/commands/agents#agentscreate) | Create and run a new agent task on your site | +| [`agents:diff`](/commands/agents#agentsdiff) | Print the unified diff produced by an agent task | +| [`agents:follow-up`](/commands/agents#agentsfollow-up) | Send a follow-up prompt to an existing agent task | | [`agents:list`](/commands/agents#agentslist) | List agent tasks for the current site | +| [`agents:open`](/commands/agents#agentsopen) | Open the agent task preview, dashboard, or pull request in a browser | +| [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent task | +| [`agents:publish`](/commands/agents#agentspublish) | Publish an agent task's changes to production | +| [`agents:redeploy`](/commands/agents#agentsredeploy) | Create a redeploy session that reapplies an existing diff (no AI inference) | +| [`agents:revert`](/commands/agents#agentsrevert) | Revert an agent task to a specific session (sessions after it are discarded) | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent task | -| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent task | +| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent task or session | ### [api](/commands/api) From 2cbd424393343880b897094d006c47cad92db4c3 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 01:48:23 +0530 Subject: [PATCH 03/18] test(agents): align integration tests with new CLI surface --- tests/integration/commands/agents/agents-list.test.ts | 3 +-- tests/integration/commands/agents/agents-show.test.ts | 9 +++++++-- tests/integration/commands/agents/agents-stop.test.ts | 11 +++++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/integration/commands/agents/agents-list.test.ts b/tests/integration/commands/agents/agents-list.test.ts index 15b4825127a..d75a4a3f55b 100644 --- a/tests/integration/commands/agents/agents-list.test.ts +++ b/tests/integration/commands/agents/agents-list.test.ts @@ -126,11 +126,10 @@ describe('agents:list command', () => { await withMockApi(routes, async ({ apiUrl, requests }) => { const cliResponse = (await callCli( - ['agents:list', '--status', 'running'], + ['agents:list', '--status', 'live'], getCLIOptions({ apiUrl, builder }), )) as string - // Check that the status filter was sent in the request const agentRequest = requests.find((r) => r.path.includes('agent_runners')) expect(agentRequest).toBeDefined() diff --git a/tests/integration/commands/agents/agents-show.test.ts b/tests/integration/commands/agents/agents-show.test.ts index 3c49ae63f1f..ee19dfd0c74 100644 --- a/tests/integration/commands/agents/agents-show.test.ts +++ b/tests/integration/commands/agents/agents-show.test.ts @@ -69,6 +69,11 @@ describe('agents:show command', () => { method: 'GET' as const, response: mockAgentRunner, }, + { + path: 'agent_runners/test_id/sessions', + method: 'GET' as const, + response: [mockAgentSession], + }, ] await withSiteBuilder(t, async (builder) => { @@ -79,9 +84,9 @@ describe('agents:show command', () => { ['agents:show', 'test_id', '--json'], getCLIOptions({ apiUrl, builder }), true, // parseJson - )) as typeof mockAgentRunner + )) as typeof mockAgentRunner & { sessions: (typeof mockAgentSession)[] } - expect(cliResponse).toEqual(mockAgentRunner) + expect(cliResponse).toEqual({ ...mockAgentRunner, sessions: [mockAgentSession] }) }) }) }) diff --git a/tests/integration/commands/agents/agents-stop.test.ts b/tests/integration/commands/agents/agents-stop.test.ts index c3f2f21e7ba..f5fd697f61d 100644 --- a/tests/integration/commands/agents/agents-stop.test.ts +++ b/tests/integration/commands/agents/agents-stop.test.ts @@ -43,7 +43,10 @@ describe('agents:stop command', () => { await builder.build() await withMockApi(routes, async ({ apiUrl }) => { - const cliResponse = (await callCli(['agents:stop', 'test_id'], getCLIOptions({ apiUrl, builder }))) as string + const cliResponse = (await callCli( + ['agents:stop', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string expect(cliResponse).toContain('Agent task stopped successfully!') expect(cliResponse).toContain('Task ID: test_id') @@ -70,7 +73,7 @@ describe('agents:stop command', () => { await withMockApi(routes, async ({ apiUrl }) => { const cliResponse = (await callCli(['agents:stop', 'test_id'], getCLIOptions({ apiUrl, builder }))) as string - expect(cliResponse).toContain('Agent task is already completed') + expect(cliResponse).toContain('Agent task is already done') }) }) }) @@ -144,7 +147,7 @@ describe('agents:stop command', () => { await withMockApi(routes, async ({ apiUrl }) => { await expect(callCli(['agents:stop', 'invalid_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( - 'Failed to stop agent task: Not found', + 'Failed to fetch agent task: Not found', ) }) }) @@ -178,7 +181,7 @@ describe('agents:stop command', () => { await withMockApi(routes, async ({ apiUrl }) => { await expect(callCli(['agents:stop', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( - 'Failed to stop agent task: Unauthorized', + 'Failed to fetch agent task: Unauthorized', ) }) }) From 0e3f6522abf6ba65a8453e7298782df8ccf40cae Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 11:57:21 +0530 Subject: [PATCH 04/18] test(agents): add integration tests for new agent subcommands --- .../commands/agents/agents-archive.test.ts | 120 +++++++++++ .../commands/agents/agents-commit.test.ts | 151 ++++++++++++++ .../commands/agents/agents-diff.test.ts | 164 +++++++++++++++ .../commands/agents/agents-follow-up.test.ts | 190 ++++++++++++++++++ .../commands/agents/agents-open.test.ts | 166 +++++++++++++++ .../commands/agents/agents-pr.test.ts | 136 +++++++++++++ .../commands/agents/agents-publish.test.ts | 122 +++++++++++ .../commands/agents/agents-redeploy.test.ts | 165 +++++++++++++++ .../commands/agents/agents-revert.test.ts | 135 +++++++++++++ tests/integration/utils/mock-api.ts | 4 + 10 files changed, 1353 insertions(+) create mode 100644 tests/integration/commands/agents/agents-archive.test.ts create mode 100644 tests/integration/commands/agents/agents-commit.test.ts create mode 100644 tests/integration/commands/agents/agents-diff.test.ts create mode 100644 tests/integration/commands/agents/agents-follow-up.test.ts create mode 100644 tests/integration/commands/agents/agents-open.test.ts create mode 100644 tests/integration/commands/agents/agents-pr.test.ts create mode 100644 tests/integration/commands/agents/agents-publish.test.ts create mode 100644 tests/integration/commands/agents/agents-redeploy.test.ts create mode 100644 tests/integration/commands/agents/agents-revert.test.ts diff --git a/tests/integration/commands/agents/agents-archive.test.ts b/tests/integration/commands/agents/agents-archive.test.ts new file mode 100644 index 00000000000..68935053f2d --- /dev/null +++ b/tests/integration/commands/agents/agents-archive.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:archive command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should archive an agent task', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/archive', + method: 'POST' as const, + response: {}, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli(['agents:archive', 'test_id'], getCLIOptions({ apiUrl, builder }))) as string + + expect(cliResponse).toContain('Agent task archived.') + expect(cliResponse).toContain('Task ID: test_id') + }) + }) + }) + + test('should return JSON when --json flag is used', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/archive', + method: 'POST' as const, + response: {}, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:archive', 'test_id', '--json'], + getCLIOptions({ apiUrl, builder }), + true, + )) as { success: boolean; id: string } + + expect(cliResponse).toEqual({ success: true, id: 'test_id' }) + }) + }) + }) + + test('should handle archive failure', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/archive', + method: 'POST' as const, + status: 404, + response: { error: 'Not found' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect(callCli(['agents:archive', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'Failed to archive: Not found', + ) + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:archive'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) + + test('should require linked site', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi([], async ({ apiUrl }) => { + await expect( + callCli( + ['agents:archive', 'test_id'], + getCLIOptions({ apiUrl, builder, env: { NETLIFY_SITE_ID: undefined } }), + ), + ).rejects.toThrow("You don't appear to be in a folder that is linked to a project") + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-commit.test.ts b/tests/integration/commands/agents/agents-commit.test.ts new file mode 100644 index 00000000000..6d112d3a033 --- /dev/null +++ b/tests/integration/commands/agents/agents-commit.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentRunner } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:commit command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should commit to a branch', async (t) => { + const runnerWithCommit = { ...mockAgentRunner, merge_commit_sha: 'abc1234' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/commit', + method: 'POST' as const, + response: runnerWithCommit, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:commit', 'test_id', '--branch', 'staging'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Committed to') + expect(cliResponse).toContain('staging') + expect(cliResponse).toContain('SHA: abc1234') + + const commitRequest = requests.find((r) => r.path.endsWith('/commit') && r.method === 'POST') + expect(commitRequest).toBeDefined() + expect(commitRequest?.body).toEqual({ target_branch: 'staging' }) + }) + }) + }) + + test('should report merge_commit_error when commit fails on the server', async (t) => { + const runnerWithError = { ...mockAgentRunner, merge_commit_error: 'merge conflict' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/commit', + method: 'POST' as const, + response: runnerWithError, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:commit', 'test_id', '--branch', 'staging'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Commit failed: merge conflict') + }) + }) + }) + + test('should return JSON when --json flag is used', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/commit', + method: 'POST' as const, + response: { ...mockAgentRunner, merge_commit_sha: 'abc1234' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:commit', 'test_id', '--branch', 'staging', '--json'], + getCLIOptions({ apiUrl, builder }), + true, + )) as typeof mockAgentRunner & { merge_commit_sha: string } + + expect(cliResponse.merge_commit_sha).toBe('abc1234') + }) + }) + }) + + test('should handle API errors', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/commit', + method: 'POST' as const, + status: 500, + response: { error: 'Internal error' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:commit', 'test_id', '--branch', 'staging'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Failed to commit: Internal error') + }) + }) + }) + + test('should require --branch when stdin is not a TTY', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:commit', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + '--branch is required when stdin is not a TTY', + ) + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:commit'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-diff.test.ts b/tests/integration/commands/agents/agents-diff.test.ts new file mode 100644 index 00000000000..bee45d0795f --- /dev/null +++ b/tests/integration/commands/agents/agents-diff.test.ts @@ -0,0 +1,164 @@ +import type express from 'express' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +const SAMPLE_DIFF = `diff --git a/foo.txt b/foo.txt +index 0000000..1111111 100644 +--- a/foo.txt ++++ b/foo.txt +@@ -1 +1 @@ +-old ++new +` + +describe('agents:diff command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should print the agent task diff', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/diff', + method: 'GET' as const, + response: (_req: express.Request, res: express.Response) => { + res.type('text/plain').send(SAMPLE_DIFF) + }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:diff', 'test_id', '--no-color'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('diff --git a/foo.txt b/foo.txt') + expect(cliResponse).toContain('+new') + expect(cliResponse).toContain('-old') + }) + }) + }) + + test('should print a session result diff', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions/session_id/diff/result', + method: 'GET' as const, + response: (_req: express.Request, res: express.Response) => { + res.type('text/plain').send(SAMPLE_DIFF) + }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:diff', 'test_id', '--session', 'session_id', '--no-color'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('+new') + }) + }) + }) + + test('should print a cumulative session diff', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions/session_id/diff/cumulative', + method: 'GET' as const, + response: (_req: express.Request, res: express.Response) => { + res.type('text/plain').send(SAMPLE_DIFF) + }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli( + ['agents:diff', 'test_id', '--session', 'session_id', '--cumulative', '--no-color'], + getCLIOptions({ apiUrl, builder }), + ) + + const diffRequest = requests.find((r) => r.path.endsWith('/diff/cumulative')) + expect(diffRequest).toBeDefined() + }) + }) + }) + + test('should report when no diff is available for the task', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/diff', + method: 'GET' as const, + status: 404, + response: { error: 'not found' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:diff', 'test_id', '--no-color'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('No diff available for this agent task.') + }) + }) + }) + + test('should reject non-positive --page', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect( + callCli(['agents:diff', 'test_id', '--page', '0'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('--page must be a positive integer') + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:diff'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-follow-up.test.ts b/tests/integration/commands/agents/agents-follow-up.test.ts new file mode 100644 index 00000000000..77749e80587 --- /dev/null +++ b/tests/integration/commands/agents/agents-follow-up.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentSession } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:follow-up command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should send a follow-up prompt and create a session', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + response: mockAgentSession, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:follow-up', 'test_id', 'Also add tests'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Follow-up session created!') + expect(cliResponse).toContain('Task ID: test_id') + expect(cliResponse).toContain('Session ID: session_id') + expect(cliResponse).toContain('Prompt: Also add tests') + + const sessionRequest = requests.find((r) => r.path.endsWith('/sessions') && r.method === 'POST') + expect(sessionRequest).toBeDefined() + expect((sessionRequest?.body as { prompt: string }).prompt).toBe('Also add tests') + }) + }) + }) + + test('should accept prompt via --prompt flag', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + response: mockAgentSession, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli( + ['agents:follow-up', 'test_id', '--prompt', 'Fix the lint error'], + getCLIOptions({ apiUrl, builder }), + ) + + const sessionRequest = requests.find((r) => r.path.endsWith('/sessions') && r.method === 'POST') + expect((sessionRequest?.body as { prompt: string }).prompt).toBe('Fix the lint error') + }) + }) + }) + + test('should pass agent and model in the request body', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/ai-gateway/providers', + method: 'GET' as const, + response: { providers: {} }, + }, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + response: mockAgentSession, + }, + { + path: 'ai-gateway/providers', + method: 'GET' as const, + response: { providers: {} }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli( + ['agents:follow-up', 'test_id', 'Update README', '--agent', 'claude', '--model', 'claude-3-sonnet'], + getCLIOptions({ apiUrl, builder }), + ) + + const sessionRequest = requests.find((r) => r.path.endsWith('/sessions') && r.method === 'POST') + expect(sessionRequest?.body).toMatchObject({ + prompt: 'Update README', + agent: 'claude', + model: 'claude-3-sonnet', + }) + }) + }) + }) + + test('should return JSON when --json flag is used', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + response: mockAgentSession, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:follow-up', 'test_id', 'Add tests', '--json'], + getCLIOptions({ apiUrl, builder }), + true, + )) as typeof mockAgentSession + + expect(cliResponse).toEqual(mockAgentSession) + }) + }) + }) + + test('should reject prompts shorter than 5 chars', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect( + callCli(['agents:follow-up', 'test_id', 'no'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('more detailed prompt') + }) + }) + }) + + test('should surface "active session" hint on conflict', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + status: 409, + response: { error: 'Cannot start: an active session is already running' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:follow-up', 'test_id', 'Add tests'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Failed to send follow-up') + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:follow-up'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-open.test.ts b/tests/integration/commands/agents/agents-open.test.ts new file mode 100644 index 00000000000..ddd726b8933 --- /dev/null +++ b/tests/integration/commands/agents/agents-open.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentRunner } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:open command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + const noBrowserEnv = { BROWSER: 'none' } + + test('should open the deploy preview URL for a task', async (t) => { + const runnerWithPreview = { ...mockAgentRunner, latest_session_deploy_url: 'https://preview.netlify.app' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerWithPreview, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('Opening') + expect(cliResponse).toContain('https://preview.netlify.app') + }) + }) + }) + + test('should fall back to dashboard when no preview is available', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: mockAgentRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('No deploy preview available') + expect(cliResponse).toContain('app.netlify.com/projects/site-name/agent-runs/test_id') + }) + }) + }) + + test('should open the dashboard when target is "dashboard"', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id', 'dashboard'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('app.netlify.com/projects/site-name/agent-runs/test_id') + }) + }) + }) + + test('should open the PR url when target is "pr"', async (t) => { + const runnerWithPr = { ...mockAgentRunner, pr_url: 'https://github.com/owner/repo/pull/42' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerWithPr, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id', 'pr'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('https://github.com/owner/repo/pull/42') + }) + }) + }) + + test('should explain when no PR exists yet', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: mockAgentRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id', 'pr'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('No pull request exists for this agent task') + expect(cliResponse).toContain('netlify agents:pr test_id') + }) + }) + }) + + test('should reject invalid targets', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect( + callCli(['agents:open', 'test_id', 'whatever'], getCLIOptions({ apiUrl, builder, env: noBrowserEnv })), + ).rejects.toThrow('Invalid target "whatever"') + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:open'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-pr.test.ts b/tests/integration/commands/agents/agents-pr.test.ts new file mode 100644 index 00000000000..e51d9f34cac --- /dev/null +++ b/tests/integration/commands/agents/agents-pr.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentRunner } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:pr command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should create a pull request', async (t) => { + const runnerWithPr = { + ...mockAgentRunner, + pr_url: 'https://github.com/owner/repo/pull/42', + pr_branch: 'agent/abc', + pr_state: 'open', + } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/pull_request', + method: 'POST' as const, + response: runnerWithPr, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli(['agents:pr', 'test_id'], getCLIOptions({ apiUrl, builder }))) as string + + expect(cliResponse).toContain('Pull request created!') + expect(cliResponse).toContain('https://github.com/owner/repo/pull/42') + expect(cliResponse).toContain('Branch: agent/abc') + expect(cliResponse).toContain('State: open') + }) + }) + }) + + test('should report pr_error returned by the API', async (t) => { + const runnerWithError = { ...mockAgentRunner, pr_error: 'no diff to base on' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/pull_request', + method: 'POST' as const, + response: runnerWithError, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli(['agents:pr', 'test_id'], getCLIOptions({ apiUrl, builder }))) as string + + expect(cliResponse).toContain('Pull request failed: no diff to base on') + }) + }) + }) + + test('should return JSON when --json flag is used', async (t) => { + const runnerWithPr = { ...mockAgentRunner, pr_url: 'https://github.com/owner/repo/pull/42' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/pull_request', + method: 'POST' as const, + response: runnerWithPr, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:pr', 'test_id', '--json'], + getCLIOptions({ apiUrl, builder }), + true, + )) as typeof mockAgentRunner & { pr_url: string } + + expect(cliResponse.pr_url).toBe('https://github.com/owner/repo/pull/42') + }) + }) + }) + + test('should handle API errors', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/pull_request', + method: 'POST' as const, + status: 500, + response: { error: 'something went wrong' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect(callCli(['agents:pr', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'Failed to create pull request: something went wrong', + ) + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:pr'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-publish.test.ts b/tests/integration/commands/agents/agents-publish.test.ts new file mode 100644 index 00000000000..3f69ee73f19 --- /dev/null +++ b/tests/integration/commands/agents/agents-publish.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentRunner } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:publish command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should publish to production with --yes', async (t) => { + const runnerWithCommit = { ...mockAgentRunner, merge_commit_sha: 'def5678' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/publish_to_production', + method: 'POST' as const, + response: runnerWithCommit, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:publish', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Published agent task to production!') + expect(cliResponse).toContain('Task ID: agent_runner_id') + expect(cliResponse).toContain('Commit: def5678') + }) + }) + }) + + test('should refuse to publish without --yes when stdin is not a TTY', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:publish', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'Refusing to publish without --yes when stdin is not a TTY', + ) + }) + }) + }) + + test('should publish without --yes if --json is set (treats it as non-interactive)', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/publish_to_production', + method: 'POST' as const, + response: mockAgentRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:publish', 'test_id', '--json'], + getCLIOptions({ apiUrl, builder }), + true, + )) as typeof mockAgentRunner + + expect(cliResponse).toEqual(mockAgentRunner) + }) + }) + }) + + test('should handle API errors', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/publish_to_production', + method: 'POST' as const, + status: 500, + response: { error: 'kaboom' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:publish', 'test_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Failed to publish: kaboom') + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:publish'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-redeploy.test.ts b/tests/integration/commands/agents/agents-redeploy.test.ts new file mode 100644 index 00000000000..a2ae7a27333 --- /dev/null +++ b/tests/integration/commands/agents/agents-redeploy.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentSession } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:redeploy command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should redeploy a specific session', async (t) => { + const newSession = { ...mockAgentSession, id: 'new_session_id', state: 'running' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions/old_session_id/redeploy', + method: 'POST' as const, + response: newSession, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:redeploy', 'test_id', '--session', 'old_session_id'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Redeploy session created!') + expect(cliResponse).toContain('Session ID: new_session_id') + expect(cliResponse).toContain('Source Session: old_session_id') + }) + }) + }) + + test('should pick the latest done session when no --session is given', async (t) => { + const completedSession = { ...mockAgentSession, id: 'done_session_id', state: 'done' } + const newSession = { ...mockAgentSession, id: 'new_session_id', state: 'running' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'GET' as const, + response: [completedSession], + }, + { + path: 'agent_runners/test_id/sessions/done_session_id/redeploy', + method: 'POST' as const, + response: newSession, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:redeploy', 'test_id'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Redeploy session created!') + expect(cliResponse).toContain('Source Session: done_session_id') + }) + }) + }) + + test('should error when no completed session exists', async (t) => { + const runningSession = { ...mockAgentSession, state: 'running' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'GET' as const, + response: [runningSession], + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect(callCli(['agents:redeploy', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'No completed session found to redeploy', + ) + }) + }) + }) + + test('should return JSON when --json flag is used', async (t) => { + const newSession = { ...mockAgentSession, id: 'new_session_id' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions/old_session_id/redeploy', + method: 'POST' as const, + response: newSession, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:redeploy', 'test_id', '--session', 'old_session_id', '--json'], + getCLIOptions({ apiUrl, builder }), + true, + )) as typeof mockAgentSession + + expect(cliResponse).toEqual(newSession) + }) + }) + }) + + test('should handle API errors when redeploying', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions/old_session_id/redeploy', + method: 'POST' as const, + status: 500, + response: { error: 'oops' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:redeploy', 'test_id', '--session', 'old_session_id'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Failed to redeploy: oops') + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:redeploy'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-revert.test.ts b/tests/integration/commands/agents/agents-revert.test.ts new file mode 100644 index 00000000000..e2610c5bc67 --- /dev/null +++ b/tests/integration/commands/agents/agents-revert.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentRunner } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:revert command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should revert to a session with --yes', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/revert', + method: 'POST' as const, + response: mockAgentRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:revert', 'test_id', '--session', 'session_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Agent task reverted!') + expect(cliResponse).toContain('Reverted to session: session_id') + + const revertRequest = requests.find((r) => r.path.endsWith('/revert') && r.method === 'POST') + expect(revertRequest?.body).toEqual({ session_id: 'session_id' }) + }) + }) + }) + + test('should refuse to revert without --yes when stdin is not a TTY', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect( + callCli(['agents:revert', 'test_id', '--session', 'session_id'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Refusing to revert without --yes when stdin is not a TTY') + }) + }) + }) + + test('should require --session', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect( + callCli(['agents:revert', 'test_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow(/required option.*--session/) + }) + }) + }) + + test('should return JSON when --json flag is used', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/revert', + method: 'POST' as const, + response: mockAgentRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:revert', 'test_id', '--session', 'session_id', '--json'], + getCLIOptions({ apiUrl, builder }), + true, + )) as typeof mockAgentRunner + + expect(cliResponse).toEqual(mockAgentRunner) + }) + }) + }) + + test('should handle API errors', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/revert', + method: 'POST' as const, + status: 500, + response: { error: 'broken' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:revert', 'test_id', '--session', 'session_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Failed to revert: broken') + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect( + callCli(['agents:revert', '--session', 'session_id'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('missing required argument') + }) + }) + }) +}) diff --git a/tests/integration/utils/mock-api.ts b/tests/integration/utils/mock-api.ts index eafd4795452..9da0d11d28b 100644 --- a/tests/integration/utils/mock-api.ts +++ b/tests/integration/utils/mock-api.ts @@ -66,6 +66,10 @@ export const startMockApi = ({ routes, silent }: MockApiOptions): Promise Date: Fri, 8 May 2026 12:12:08 +0530 Subject: [PATCH 05/18] feat(agents): add --yes confirm to archive and bump session list default --- docs/commands/agents.md | 2 ++ src/commands/agents/agents-archive.ts | 19 ++++++++++++- src/commands/agents/agents.ts | 6 ++++- src/commands/agents/api.ts | 2 +- .../commands/agents/agents-archive.test.ts | 27 ++++++++++++++----- 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/docs/commands/agents.md b/docs/commands/agents.md index bdac39cb5a6..9c9b0e82036 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -73,6 +73,7 @@ netlify agents:archive - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) +- `yes` (*boolean*) - skip confirmation prompt - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in @@ -80,6 +81,7 @@ netlify agents:archive ```bash netlify agents:archive 60c7c3b3e7b4a0001f5e4b3a +netlify agents:archive 60c7c3b3e7b4a0001f5e4b3a --yes ``` --- diff --git a/src/commands/agents/agents-archive.ts b/src/commands/agents/agents-archive.ts index 5d49472c7be..87fac1e8a11 100644 --- a/src/commands/agents/agents-archive.ts +++ b/src/commands/agents/agents-archive.ts @@ -1,12 +1,14 @@ import type { OptionValues } from 'commander' +import inquirer from 'inquirer' -import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { chalk, exit, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' import { createAgentsApi } from './api.js' interface AgentArchiveOptions extends OptionValues { json?: boolean + yes?: boolean } export const agentsArchive = async (id: string, options: AgentArchiveOptions, command: BaseCommand) => { @@ -14,6 +16,21 @@ export const agentsArchive = async (id: string, options: AgentArchiveOptions, co await command.authenticate() const api = createAgentsApi(command.netlify) + if (!options.yes && !options.json) { + if (!process.stdout.isTTY) { + return logAndThrowError('Refusing to archive without --yes when stdin is not a TTY') + } + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message: `Archive agent task ${id}?`, + default: false, + }, + ]) + if (!confirmed) return exit() + } + const spinner = startSpinner({ text: 'Archiving agent task...' }) try { await api.archiveAgentRunner(id) diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index 765e280cef6..8e33d7fe3f4 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -231,10 +231,14 @@ export const createAgentsCommand = (program: BaseCommand) => { .command('agents:archive') .argument('', 'agent task ID') .description('Archive an agent task') + .option('-y, --yes', 'skip confirmation prompt') .option('--json', 'output result as JSON') .option('--project ', 'project ID or name (if not in a linked directory)') .hook('preAction', requiresSiteInfoWithProject) - .addExamples(['netlify agents:archive 60c7c3b3e7b4a0001f5e4b3a']) + .addExamples([ + 'netlify agents:archive 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:archive 60c7c3b3e7b4a0001f5e4b3a --yes', + ]) .action(async (id: string, options: OptionValues, command: BaseCommand) => { const { agentsArchive } = await import('./agents-archive.js') await agentsArchive(id, options, command) diff --git a/src/commands/agents/api.ts b/src/commands/agents/api.ts index 305878f2474..322934e16d4 100644 --- a/src/commands/agents/api.ts +++ b/src/commands/agents/api.ts @@ -129,7 +129,7 @@ export const createAgentsApi = (netlify: NetlifyContext) => { filters: ListAgentRunnerSessionsFilters = {}, ): Promise => { const page = filters.page ?? 1 - const perPage = filters.per_page ?? 5 + const perPage = filters.per_page ?? DEFAULT_PER_PAGE const params = buildSearchParams({ ...filters, page, per_page: perPage }) return requestJson(`/agent_runners/${id}/sessions?${params.toString()}`, getInit()) } diff --git a/tests/integration/commands/agents/agents-archive.test.ts b/tests/integration/commands/agents/agents-archive.test.ts index 68935053f2d..6a4d8f9452c 100644 --- a/tests/integration/commands/agents/agents-archive.test.ts +++ b/tests/integration/commands/agents/agents-archive.test.ts @@ -22,7 +22,7 @@ describe('agents:archive command', () => { { path: 'accounts', response: [{ slug: 'test-account' }] }, ] - test('should archive an agent task', async (t) => { + test('should archive an agent task with --yes', async (t) => { const routes = [ ...baseRoutes, { @@ -36,7 +36,10 @@ describe('agents:archive command', () => { await builder.build() await withMockApi(routes, async ({ apiUrl }) => { - const cliResponse = (await callCli(['agents:archive', 'test_id'], getCLIOptions({ apiUrl, builder }))) as string + const cliResponse = (await callCli( + ['agents:archive', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string expect(cliResponse).toContain('Agent task archived.') expect(cliResponse).toContain('Task ID: test_id') @@ -44,6 +47,18 @@ describe('agents:archive command', () => { }) }) + test('should refuse to archive without --yes when stdin is not a TTY', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:archive', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'Refusing to archive without --yes when stdin is not a TTY', + ) + }) + }) + }) + test('should return JSON when --json flag is used', async (t) => { const routes = [ ...baseRoutes, @@ -84,9 +99,9 @@ describe('agents:archive command', () => { await builder.build() await withMockApi(routes, async ({ apiUrl }) => { - await expect(callCli(['agents:archive', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( - 'Failed to archive: Not found', - ) + await expect( + callCli(['agents:archive', 'test_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Failed to archive: Not found') }) }) }) @@ -110,7 +125,7 @@ describe('agents:archive command', () => { await withMockApi([], async ({ apiUrl }) => { await expect( callCli( - ['agents:archive', 'test_id'], + ['agents:archive', 'test_id', '--yes'], getCLIOptions({ apiUrl, builder, env: { NETLIFY_SITE_ID: undefined } }), ), ).rejects.toThrow("You don't appear to be in a folder that is linked to a project") From d9608bb879a0654defb3b823847c3a5d530ccdbf Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 12:54:19 +0530 Subject: [PATCH 06/18] fix(agents): apply CodeRabbit review feedback --- docs/commands/agents.md | 8 +++--- docs/index.md | 4 +-- src/commands/agents/agents-archive.ts | 2 +- src/commands/agents/agents-commit.ts | 4 +-- src/commands/agents/agents-create.ts | 9 ++++--- src/commands/agents/agents-diff.ts | 5 ++-- src/commands/agents/agents-list.ts | 9 ++++--- src/commands/agents/agents-publish.ts | 2 +- src/commands/agents/agents-redeploy.ts | 11 ++++++-- src/commands/agents/agents-revert.ts | 2 +- src/commands/agents/agents-stop.ts | 2 +- src/commands/agents/agents.ts | 4 +-- src/commands/agents/attachments.ts | 26 +++++++++++++++---- .../commands/agents/agents-follow-up.test.ts | 5 ---- 14 files changed, 57 insertions(+), 36 deletions(-) diff --git a/docs/commands/agents.md b/docs/commands/agents.md index 9c9b0e82036..738574aff65 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -28,14 +28,14 @@ netlify agents | Subcommand | description | |:--------------------------- |:-----| | [`agents:archive`](/commands/agents#agentsarchive) | Archive an agent task | -| [`agents:commit`](/commands/agents#agentscommit) | Commit an agent task's changes directly to a branch | +| [`agents:commit`](/commands/agents#agentscommit) | Commit an agent task’s changes directly to a branch | | [`agents:create`](/commands/agents#agentscreate) | Create and run a new agent task on your site | | [`agents:diff`](/commands/agents#agentsdiff) | Print the unified diff produced by an agent task | | [`agents:follow-up`](/commands/agents#agentsfollow-up) | Send a follow-up prompt to an existing agent task | | [`agents:list`](/commands/agents#agentslist) | List agent tasks for the current site | | [`agents:open`](/commands/agents#agentsopen) | Open the agent task preview, dashboard, or pull request in a browser | | [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent task | -| [`agents:publish`](/commands/agents#agentspublish) | Publish an agent task's changes to production | +| [`agents:publish`](/commands/agents#agentspublish) | Publish an agent task’s changes to production | | [`agents:redeploy`](/commands/agents#agentsredeploy) | Create a redeploy session that reapplies an existing diff (no AI inference) | | [`agents:revert`](/commands/agents#agentsrevert) | Revert an agent task to a specific session (sessions after it are discarded) | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent task | @@ -87,7 +87,7 @@ netlify agents:archive 60c7c3b3e7b4a0001f5e4b3a --yes --- ## `agents:commit` -Commit an agent task's changes directly to a branch +Commit an agent task’s changes directly to a branch **Usage** @@ -332,7 +332,7 @@ netlify agents:pr 60c7c3b3e7b4a0001f5e4b3a --- ## `agents:publish` -Publish an agent task's changes to production +Publish an agent task’s changes to production **Usage** diff --git a/docs/index.md b/docs/index.md index ec199aa2438..44f8acb5f50 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,14 +25,14 @@ Manage Netlify AI agent tasks | Subcommand | description | |:--------------------------- |:-----| | [`agents:archive`](/commands/agents#agentsarchive) | Archive an agent task | -| [`agents:commit`](/commands/agents#agentscommit) | Commit an agent task's changes directly to a branch | +| [`agents:commit`](/commands/agents#agentscommit) | Commit an agent task’s changes directly to a branch | | [`agents:create`](/commands/agents#agentscreate) | Create and run a new agent task on your site | | [`agents:diff`](/commands/agents#agentsdiff) | Print the unified diff produced by an agent task | | [`agents:follow-up`](/commands/agents#agentsfollow-up) | Send a follow-up prompt to an existing agent task | | [`agents:list`](/commands/agents#agentslist) | List agent tasks for the current site | | [`agents:open`](/commands/agents#agentsopen) | Open the agent task preview, dashboard, or pull request in a browser | | [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent task | -| [`agents:publish`](/commands/agents#agentspublish) | Publish an agent task's changes to production | +| [`agents:publish`](/commands/agents#agentspublish) | Publish an agent task’s changes to production | | [`agents:redeploy`](/commands/agents#agentsredeploy) | Create a redeploy session that reapplies an existing diff (no AI inference) | | [`agents:revert`](/commands/agents#agentsrevert) | Revert an agent task to a specific session (sessions after it are discarded) | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent task | diff --git a/src/commands/agents/agents-archive.ts b/src/commands/agents/agents-archive.ts index 87fac1e8a11..c66d7bd518d 100644 --- a/src/commands/agents/agents-archive.ts +++ b/src/commands/agents/agents-archive.ts @@ -17,7 +17,7 @@ export const agentsArchive = async (id: string, options: AgentArchiveOptions, co const api = createAgentsApi(command.netlify) if (!options.yes && !options.json) { - if (!process.stdout.isTTY) { + if (!process.stdin.isTTY) { return logAndThrowError('Refusing to archive without --yes when stdin is not a TTY') } const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ diff --git a/src/commands/agents/agents-commit.ts b/src/commands/agents/agents-commit.ts index 4db95abfa7b..164e24e7ad8 100644 --- a/src/commands/agents/agents-commit.ts +++ b/src/commands/agents/agents-commit.ts @@ -16,9 +16,9 @@ export const agentsCommit = async (id: string, options: AgentCommitOptions, comm await command.authenticate() const api = createAgentsApi(command.netlify) - let targetBranch = options.branch + let targetBranch = options.branch?.trim() if (!targetBranch) { - if (!process.stdout.isTTY) { + if (!process.stdin.isTTY) { return logAndThrowError('--branch is required when stdin is not a TTY') } const { branchInput } = await inquirer.prompt<{ branchInput: string }>([ diff --git a/src/commands/agents/agents-create.ts b/src/commands/agents/agents-create.ts index 8db4d641706..d1bbde74f03 100644 --- a/src/commands/agents/agents-create.ts +++ b/src/commands/agents/agents-create.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process' +import { execSync, spawnSync } from 'child_process' import type { OptionValues } from 'commander' import inquirer from 'inquirer' @@ -66,8 +66,11 @@ const detectLocalGit = (): LocalGitInfo => { try { const upstream = run('git rev-parse --abbrev-ref --symbolic-full-name @{u}') if (upstream) { - const ahead = run(`git rev-list --count ${upstream}..HEAD`) - hasUnpushedCommits = Number.parseInt(ahead, 10) > 0 + const result = spawnSync('git', ['rev-list', '--count', `${upstream}..HEAD`], { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }) + hasUnpushedCommits = Number.parseInt(result.stdout.trim(), 10) > 0 } } catch { // No upstream configured: can't tell. diff --git a/src/commands/agents/agents-diff.ts b/src/commands/agents/agents-diff.ts index 922871a3498..e7d5141f30a 100644 --- a/src/commands/agents/agents-diff.ts +++ b/src/commands/agents/agents-diff.ts @@ -17,11 +17,10 @@ interface AgentDiffOptions extends OptionValues { const parsePositiveInt = (input: string | undefined, name: string): number | undefined => { if (input === undefined) return undefined - const value = Number.parseInt(input, 10) - if (!Number.isInteger(value) || value <= 0) { + if (!/^[1-9]\d*$/.test(input)) { throw new Error(`--${name} must be a positive integer`) } - return value + return Number.parseInt(input, 10) } export const agentsDiff = async (id: string, options: AgentDiffOptions, command: BaseCommand) => { diff --git a/src/commands/agents/agents-list.ts b/src/commands/agents/agents-list.ts index c7f78cab216..98a9e14d3b2 100644 --- a/src/commands/agents/agents-list.ts +++ b/src/commands/agents/agents-list.ts @@ -33,11 +33,10 @@ const toUnixSeconds = (input?: string): number | undefined => { const parsePositiveInt = (input: string | undefined, name: string): number | undefined => { if (input === undefined) return undefined - const value = Number.parseInt(input, 10) - if (!Number.isInteger(value) || value <= 0) { + if (!/^[1-9]\d*$/.test(input)) { throw new Error(`--${name} must be a positive integer`) } - return value + return Number.parseInt(input, 10) } const buildFilters = (options: AgentListOptions): ListAgentRunnersFilters => { @@ -151,13 +150,15 @@ const fetchLatestAgentByRunner = async ( return result } +const escapeRegex = (input: string): string => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const colorizeStatuses = (tableOutput: string, runners: AgentRunner[]): string => { let output = tableOutput const statuses = new Set(runners.map((runner) => runner.state ?? 'unknown')) for (const status of statuses) { const plain = status.toUpperCase() const colored = formatStatus(status) - output = output.replace(new RegExp(`\\b${plain}\\b`, 'g'), colored) + output = output.replace(new RegExp(`\\b${escapeRegex(plain)}\\b`, 'g'), colored) } return output } diff --git a/src/commands/agents/agents-publish.ts b/src/commands/agents/agents-publish.ts index 5f6a16b62ea..17b2c8e2449 100644 --- a/src/commands/agents/agents-publish.ts +++ b/src/commands/agents/agents-publish.ts @@ -18,7 +18,7 @@ export const agentsPublish = async (id: string, options: AgentPublishOptions, co const api = createAgentsApi(command.netlify) if (!options.yes && !options.json) { - if (!process.stdout.isTTY) { + if (!process.stdin.isTTY) { return logAndThrowError('Refusing to publish without --yes when stdin is not a TTY') } log(chalk.redBright('Warning'), 'You are about to publish agent changes to production.') diff --git a/src/commands/agents/agents-redeploy.ts b/src/commands/agents/agents-redeploy.ts index 7f1308467f7..c1b6e3b4369 100644 --- a/src/commands/agents/agents-redeploy.ts +++ b/src/commands/agents/agents-redeploy.ts @@ -20,9 +20,16 @@ export const agentsRedeploy = async (id: string, options: AgentRedeployOptions, if (!sessionId) { const lookupSpinner = startSpinner({ text: 'Finding latest completed session...' }) try { - const sessions = await api.listAgentRunnerSessions(id, { page: 1, per_page: 20 }) + const perPage = 100 + let page = 1 + let latestDone: { id: string } | undefined + while (!latestDone) { + const sessions = await api.listAgentRunnerSessions(id, { page, per_page: perPage }) + latestDone = sessions.find((session) => session.state === 'done') + if (latestDone || sessions.length < perPage) break + page += 1 + } stopSpinner({ spinner: lookupSpinner }) - const latestDone = sessions.find((session) => session.state === 'done') if (!latestDone) { return logAndThrowError('No completed session found to redeploy. Pass --session to target a specific one.') } diff --git a/src/commands/agents/agents-revert.ts b/src/commands/agents/agents-revert.ts index 06fdb071d03..b8e6487d6b3 100644 --- a/src/commands/agents/agents-revert.ts +++ b/src/commands/agents/agents-revert.ts @@ -19,7 +19,7 @@ export const agentsRevert = async (id: string, options: AgentRevertOptions, comm const api = createAgentsApi(command.netlify) if (!options.yes && !options.json) { - if (!process.stdout.isTTY) { + if (!process.stdin.isTTY) { return logAndThrowError('Refusing to revert without --yes when stdin is not a TTY') } const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ diff --git a/src/commands/agents/agents-stop.ts b/src/commands/agents/agents-stop.ts index 0dc0bf298e0..ff7f3e861e6 100644 --- a/src/commands/agents/agents-stop.ts +++ b/src/commands/agents/agents-stop.ts @@ -126,7 +126,7 @@ const stopSession = async ( } const confirmStop = async (message: string): Promise => { - if (!process.stdout.isTTY) { + if (!process.stdin.isTTY) { return logAndThrowError('Refusing to stop without --yes when stdin is not a TTY') } const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index 8e33d7fe3f4..0c5c73c9736 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -184,7 +184,7 @@ export const createAgentsCommand = (program: BaseCommand) => { program .command('agents:commit') .argument('', 'agent task ID') - .description("Commit an agent task's changes directly to a branch") + .description('Commit an agent task’s changes directly to a branch') .option('-b, --branch ', 'target branch to commit to') .option('--json', 'output result as JSON') .option('--project ', 'project ID or name (if not in a linked directory)') @@ -198,7 +198,7 @@ export const createAgentsCommand = (program: BaseCommand) => { program .command('agents:publish') .argument('', 'agent task ID') - .description("Publish an agent task's changes to production") + .description('Publish an agent task’s changes to production') .option('-y, --yes', 'skip confirmation prompt') .option('--json', 'output result as JSON') .option('--project ', 'project ID or name (if not in a linked directory)') diff --git a/src/commands/agents/attachments.ts b/src/commands/agents/attachments.ts index 76551ecbbcf..ef277fad2b1 100644 --- a/src/commands/agents/attachments.ts +++ b/src/commands/agents/attachments.ts @@ -48,11 +48,27 @@ export const uploadAttachments = async ( }) const body = await fs.readFile(file.path) - const putResponse = await fetch(uploadUrl, { - method: 'PUT', - body: new Uint8Array(body), - headers: { 'Content-Type': file.contentType }, - }) + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, 60_000) + let putResponse: Response + try { + putResponse = await fetch(uploadUrl, { + method: 'PUT', + body: new Uint8Array(body), + headers: { 'Content-Type': file.contentType }, + signal: controller.signal, + }) + } catch (error_) { + const error = error_ as Error + if (error.name === 'AbortError') { + throw new Error(`Upload of ${file.filename} timed out after 60s`) + } + throw error + } finally { + clearTimeout(timeout) + } if (!putResponse.ok) { throw new Error( `Failed to upload ${file.filename}: HTTP ${putResponse.status.toString()} ${putResponse.statusText}`, diff --git a/tests/integration/commands/agents/agents-follow-up.test.ts b/tests/integration/commands/agents/agents-follow-up.test.ts index 77749e80587..65768730454 100644 --- a/tests/integration/commands/agents/agents-follow-up.test.ts +++ b/tests/integration/commands/agents/agents-follow-up.test.ts @@ -81,11 +81,6 @@ describe('agents:follow-up command', () => { test('should pass agent and model in the request body', async (t) => { const routes = [ ...baseRoutes, - { - path: 'agent_runners/ai-gateway/providers', - method: 'GET' as const, - response: { providers: {} }, - }, { path: 'agent_runners/test_id/sessions', method: 'POST' as const, From 59b6595fc817c073af8b786bf2bae6f54a69903d Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 15:36:56 +0530 Subject: [PATCH 07/18] fix(agents): harden review concerns (concurrency cap, bounded pagination, watch resilience) --- src/commands/agents/agents-list.ts | 19 ++++++++++++------- src/commands/agents/agents-open.ts | 9 ++++++--- src/commands/agents/agents-redeploy.ts | 3 ++- src/commands/agents/agents-show.ts | 19 +++++++++++++------ src/commands/agents/api.ts | 2 ++ 5 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/commands/agents/agents-list.ts b/src/commands/agents/agents-list.ts index 98a9e14d3b2..d2b928dc3e1 100644 --- a/src/commands/agents/agents-list.ts +++ b/src/commands/agents/agents-list.ts @@ -135,18 +135,23 @@ export const agentsList = async (options: AgentListOptions, command: BaseCommand } } +const AGENT_LOOKUP_CONCURRENCY = 10 + const fetchLatestAgentByRunner = async ( api: ReturnType, runners: AgentRunner[], ): Promise> => { const result = new Map() - await Promise.allSettled( - runners.map(async (runner) => { - const sessions = await api.listAgentRunnerSessions(runner.id, { page: 1, per_page: 1 }) - const agent = sessions[0]?.agent_config?.agent - if (agent) result.set(runner.id, agent) - }), - ) + for (let start = 0; start < runners.length; start += AGENT_LOOKUP_CONCURRENCY) { + const batch = runners.slice(start, start + AGENT_LOOKUP_CONCURRENCY) + await Promise.allSettled( + batch.map(async (runner) => { + const sessions = await api.listAgentRunnerSessions(runner.id, { page: 1, per_page: 1 }) + const agent = sessions[0]?.agent_config?.agent + if (agent) result.set(runner.id, agent) + }), + ) + } return result } diff --git a/src/commands/agents/agents-open.ts b/src/commands/agents/agents-open.ts index 538dec2348a..2e2577e9016 100644 --- a/src/commands/agents/agents-open.ts +++ b/src/commands/agents/agents-open.ts @@ -9,6 +9,8 @@ import { createAgentsApi } from './api.js' const VALID_TARGETS = ['preview', 'dashboard', 'pr'] as const type OpenTarget = (typeof VALID_TARGETS)[number] +const isOpenTarget = (input: string): input is OpenTarget => (VALID_TARGETS as readonly string[]).includes(input) + interface AgentOpenOptions extends OptionValues { json?: boolean } @@ -21,10 +23,11 @@ export const agentsOpen = async ( ) => { if (!id) return logAndThrowError('Agent task ID is required') - const target: OpenTarget = (targetArg ?? 'preview') as OpenTarget - if (!VALID_TARGETS.includes(target)) { - return logAndThrowError(`Invalid target "${target}". Choose one of: ${VALID_TARGETS.join(', ')}`) + const candidate = targetArg ?? 'preview' + if (!isOpenTarget(candidate)) { + return logAndThrowError(`Invalid target "${candidate}". Choose one of: ${VALID_TARGETS.join(', ')}`) } + const target: OpenTarget = candidate await command.authenticate() const { siteInfo } = command.netlify diff --git a/src/commands/agents/agents-redeploy.ts b/src/commands/agents/agents-redeploy.ts index c1b6e3b4369..32a13f1182f 100644 --- a/src/commands/agents/agents-redeploy.ts +++ b/src/commands/agents/agents-redeploy.ts @@ -21,9 +21,10 @@ export const agentsRedeploy = async (id: string, options: AgentRedeployOptions, const lookupSpinner = startSpinner({ text: 'Finding latest completed session...' }) try { const perPage = 100 + const maxPages = 10 let page = 1 let latestDone: { id: string } | undefined - while (!latestDone) { + while (!latestDone && page <= maxPages) { const sessions = await api.listAgentRunnerSessions(id, { page, per_page: perPage }) latestDone = sessions.find((session) => session.state === 'done') if (latestDone || sessions.length < perPage) break diff --git a/src/commands/agents/agents-show.ts b/src/commands/agents/agents-show.ts index 5da352f4b24..391c8649157 100644 --- a/src/commands/agents/agents-show.ts +++ b/src/commands/agents/agents-show.ts @@ -338,8 +338,10 @@ const takeSnapshot = (runner: AgentRunner, sessions: AgentRunnerSession[]): Watc const watchAgentTask = async (api: AgentsApi, id: string, command: BaseCommand) => { const renderer = new WatchRenderer() let previous: WatchSnapshot | null = null - let lastRunner: AgentRunner = await api.getAgentRunner(id) - let lastSessions: AgentRunnerSession[] = await api.listAgentRunnerSessions(id, { page: 1, per_page: 100 }) + let [lastRunner, lastSessions] = await Promise.all([ + api.getAgentRunner(id), + api.listAgentRunnerSessions(id, { page: 1, per_page: 100 }), + ]) log(`${chalk.cyan('Watching')} agent task ${chalk.bold(id)} ${chalk.dim('(Ctrl+C to stop)')}`) log() @@ -357,10 +359,15 @@ const watchAgentTask = async (api: AgentsApi, id: string, command: BaseCommand) break } await sleep(POLL_INTERVAL_MS) - ;[lastRunner, lastSessions] = await Promise.all([ - api.getAgentRunner(id), - api.listAgentRunnerSessions(id, { page: 1, per_page: 100 }), - ]) + try { + ;[lastRunner, lastSessions] = await Promise.all([ + api.getAgentRunner(id), + api.listAgentRunnerSessions(id, { page: 1, per_page: 100 }), + ]) + } catch (error_) { + const error = error_ as Error + renderer.print(`${chalk.yellow('!')} ${chalk.dim(`poll failed: ${error.message} — retrying`)}`) + } } } finally { renderer.stop() diff --git a/src/commands/agents/api.ts b/src/commands/agents/api.ts index 322934e16d4..b7ec1560340 100644 --- a/src/commands/agents/api.ts +++ b/src/commands/agents/api.ts @@ -199,6 +199,8 @@ export const createAgentsApi = (netlify: NetlifyContext) => { let providersCache: AiGatewayProvidersResponse | null = null const listAiGatewayProviders = async (): Promise => { if (providersCache) return providersCache + // Public endpoint by design — no auth header. The provider+model list is meant + // for external clients to discover the agent → provider → model relationship. const response = await fetch(`${baseUrl}/ai-gateway/providers`) if (!response.ok) await throwForStatus(response) providersCache = (await response.json()) as AiGatewayProvidersResponse From 5584c329a4bb84c63bbb8e490febc341c3a0aba2 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 18:04:27 +0530 Subject: [PATCH 08/18] fix(agents): suppress prompts in --json mode; clearer 404 messages --- src/commands/agents/agents-create.ts | 35 +++++++++++++++---- src/commands/agents/agents-diff.ts | 22 +++++++++++- src/commands/agents/agents-show.ts | 10 ++++-- src/commands/agents/api.ts | 6 +++- .../commands/agents/agents-diff.test.ts | 5 +++ .../commands/agents/agents-show.test.ts | 2 +- 6 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/commands/agents/agents-create.ts b/src/commands/agents/agents-create.ts index d1bbde74f03..767df943ea0 100644 --- a/src/commands/agents/agents-create.ts +++ b/src/commands/agents/agents-create.ts @@ -97,14 +97,14 @@ export const agentsCreate = async (promptArg: string, options: AgentCreateOption if (valid !== true) return logAndThrowError(valid) } - const finalPrompt = await resolvePrompt(promptArg, options.prompt) - const agent = await resolveAgent(options.agent) + const finalPrompt = await resolvePrompt(promptArg, options.prompt, options) + const agent = await resolveAgent(options.agent, options) const isGitBased = Boolean(siteInfo.build_settings?.repo_branch) let branch: string | undefined if (isGitBased && !options.fromDeploy) { - branch = await resolveBranch(options.branch, siteInfo.build_settings?.repo_branch) + branch = await resolveBranch(options.branch, siteInfo.build_settings?.repo_branch, options) } const api = createAgentsApi(command.netlify) @@ -184,8 +184,17 @@ export const agentsCreate = async (promptArg: string, options: AgentCreateOption } } -const resolvePrompt = async (promptArg: string, promptFlag?: string): Promise => { +const isNonInteractive = (options: AgentCreateOptions): boolean => Boolean(options.json) + +const resolvePrompt = async ( + promptArg: string, + promptFlag: string | undefined, + options: AgentCreateOptions, +): Promise => { if (!promptArg && !promptFlag) { + if (isNonInteractive(options)) { + return logAndThrowError('A prompt is required. Pass it as the positional argument or via --prompt.') + } const { promptInput } = await inquirer.prompt<{ promptInput: string }>([ { type: 'input', @@ -204,8 +213,13 @@ const resolvePrompt = async (promptArg: string, promptFlag?: string): Promise => { +const resolveAgent = async (agentFlag: string | undefined, options: AgentCreateOptions): Promise => { if (!agentFlag) { + if (isNonInteractive(options)) { + return logAndThrowError( + `--agent is required. Choose one of: ${AVAILABLE_AGENTS.map((entry) => entry.value).join(', ')}.`, + ) + } const { agentInput } = await inquirer.prompt<{ agentInput: AvailableAgent }>([ { type: 'list', @@ -222,12 +236,21 @@ const resolveAgent = async (agentFlag?: string): Promise => { return agentFlag as AvailableAgent } -const resolveBranch = async (branchFlag: string | undefined, siteBranch: string | undefined): Promise => { +const resolveBranch = async ( + branchFlag: string | undefined, + siteBranch: string | undefined, + options: AgentCreateOptions, +): Promise => { if (branchFlag) return branchFlag const localGit = detectLocalGit() const defaultBranch = localGit.branch ?? siteBranch + if (isNonInteractive(options)) { + if (defaultBranch) return defaultBranch + return logAndThrowError('--branch is required when not running interactively.') + } + if (localGit.isInsideRepo) { if (localGit.isDirty) { log(chalk.yellow('⚠ Local working tree has uncommitted changes. The agent runs against the remote branch.')) diff --git a/src/commands/agents/agents-diff.ts b/src/commands/agents/agents-diff.ts index e7d5141f30a..7337eb31458 100644 --- a/src/commands/agents/agents-diff.ts +++ b/src/commands/agents/agents-diff.ts @@ -3,7 +3,7 @@ import type { OptionValues } from 'commander' import { chalk, log, logAndThrowError } from '../../utils/command-helpers.js' import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' -import { createAgentsApi } from './api.js' +import { createAgentsApi, type AgentsApi } from './api.js' import { formatDiff } from './utils.js' interface AgentDiffOptions extends OptionValues { @@ -23,6 +23,18 @@ const parsePositiveInt = (input: string | undefined, name: string): number | und return Number.parseInt(input, 10) } +const verifyRunnerExists = async (api: AgentsApi, id: string): Promise => { + try { + await api.getAgentRunner(id) + } catch (error_) { + const error = error_ as Error & { status?: number } + if (error.status === 404) { + throw new Error(`Agent task not found: ${id}`) + } + throw error + } +} + export const agentsDiff = async (id: string, options: AgentDiffOptions, command: BaseCommand) => { if (!id) return logAndThrowError('Agent task ID is required') await command.authenticate() @@ -39,6 +51,7 @@ export const agentsDiff = async (id: string, options: AgentDiffOptions, command: : await api.getSessionResultDiff(id, options.session) stopSpinner({ spinner }) if (!diff) { + await verifyRunnerExists(api, id) log(chalk.yellow('No diff available for this session.')) return } @@ -48,6 +61,9 @@ export const agentsDiff = async (id: string, options: AgentDiffOptions, command: } catch (error_) { stopSpinner({ spinner, error: true }) const error = error_ as Error + if (error.message.startsWith('Agent task not found:')) { + return logAndThrowError(error.message) + } return logAndThrowError(`Failed to fetch diff: ${error.message}`) } } @@ -71,6 +87,7 @@ export const agentsDiff = async (id: string, options: AgentDiffOptions, command: stopSpinner({ spinner }) if (!result.data) { + await verifyRunnerExists(api, id) log(chalk.yellow('No diff available for this agent task.')) return } @@ -84,6 +101,9 @@ export const agentsDiff = async (id: string, options: AgentDiffOptions, command: } catch (error_) { stopSpinner({ spinner, error: true }) const error = error_ as Error + if (error.message.startsWith('Agent task not found:')) { + return logAndThrowError(error.message) + } return logAndThrowError(`Failed to fetch diff: ${error.message}`) } } diff --git a/src/commands/agents/agents-show.ts b/src/commands/agents/agents-show.ts index 391c8649157..575279355cc 100644 --- a/src/commands/agents/agents-show.ts +++ b/src/commands/agents/agents-show.ts @@ -100,8 +100,11 @@ const showAgentTask = async (api: AgentsApi, id: string, options: AgentShowOptio renderAgentTask(runner, sessions, command) return runner } catch (error_) { - const error = error_ as Error + const error = error_ as Error & { status?: number } stopSpinner({ spinner, error: true }) + if (error.status === 404) { + return logAndThrowError(`Agent task not found: ${id}`) + } return logAndThrowError(`Failed to show agent task: ${error.message}`) } } @@ -126,8 +129,11 @@ const showSingleSession = async ( renderSessionDetail(session, id, command) return session } catch (error_) { - const error = error_ as Error + const error = error_ as Error & { status?: number } stopSpinner({ spinner, error: true }) + if (error.status === 404) { + return logAndThrowError(`Session not found: ${sessionId}`) + } return logAndThrowError(`Failed to show session: ${error.message}`) } } diff --git a/src/commands/agents/api.ts b/src/commands/agents/api.ts index b7ec1560340..070dd987dd6 100644 --- a/src/commands/agents/api.ts +++ b/src/commands/agents/api.ts @@ -51,7 +51,11 @@ export const createAgentsApi = (netlify: NetlifyContext) => { const throwForStatus = async (response: Response): Promise => { const errorData = (await response.json().catch(() => ({}))) as { error?: string } - throw new Error(errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`) + const error = new Error( + errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`, + ) as Error & { status?: number } + error.status = response.status + throw error } const requestRaw = async (path: string, init: RequestInit, handler: RawResponseHandler): Promise => { diff --git a/tests/integration/commands/agents/agents-diff.test.ts b/tests/integration/commands/agents/agents-diff.test.ts index bee45d0795f..a0247cf0331 100644 --- a/tests/integration/commands/agents/agents-diff.test.ts +++ b/tests/integration/commands/agents/agents-diff.test.ts @@ -122,6 +122,11 @@ describe('agents:diff command', () => { status: 404, response: { error: 'not found' }, }, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: { id: 'test_id', state: 'done' }, + }, ] await withSiteBuilder(t, async (builder) => { diff --git a/tests/integration/commands/agents/agents-show.test.ts b/tests/integration/commands/agents/agents-show.test.ts index ee19dfd0c74..0cabc174a1a 100644 --- a/tests/integration/commands/agents/agents-show.test.ts +++ b/tests/integration/commands/agents/agents-show.test.ts @@ -107,7 +107,7 @@ describe('agents:show command', () => { await withMockApi(routes, async ({ apiUrl }) => { await expect(callCli(['agents:show', 'invalid_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( - 'Failed to show agent task: Not found', + 'Agent task not found: invalid_id', ) }) }) From c42c935f473537171732dcdd2530ec027b4e1de5 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Fri, 8 May 2026 19:02:43 +0530 Subject: [PATCH 09/18] perf(agents): drop AGENT column from list to remove N+1 session lookup --- src/commands/agents/agents-list.ts | 27 ++----------------- .../commands/agents/agents-list.test.ts | 27 +------------------ 2 files changed, 3 insertions(+), 51 deletions(-) diff --git a/src/commands/agents/agents-list.ts b/src/commands/agents/agents-list.ts index d2b928dc3e1..f1461ef4ce0 100644 --- a/src/commands/agents/agents-list.ts +++ b/src/commands/agents/agents-list.ts @@ -6,7 +6,7 @@ import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' import { createAgentsApi } from './api.js' import type { AgentRunner, ListAgentRunnersFilters } from './types.js' -import { formatDuration, formatStatus, getAgentName, truncateText } from './utils.js' +import { formatDuration, formatStatus, truncateText } from './utils.js' interface AgentListOptions extends OptionValues { status?: string @@ -98,20 +98,17 @@ export const agentsList = async (options: AgentListOptions, command: BaseCommand return result.data } - const agentInfo = await fetchLatestAgentByRunner(api, result.data) - const isGitBased = Boolean(siteInfo.build_settings?.repo_branch) const scope = options.account ? `account ${options.account}` : siteInfo.name const table = new AsciiTable(`Agent Tasks for ${scope}`) const baseColumnLabel = isGitBased ? 'BRANCH' : 'BASE' - table.setHeading('ID', 'STATUS', 'AGENT', 'PROMPT', baseColumnLabel, 'DURATION', 'CREATED') + table.setHeading('ID', 'STATUS', 'PROMPT', baseColumnLabel, 'DURATION', 'CREATED') for (const runner of result.data) { const baseValue = isGitBased ? truncateText(runner.branch ?? 'unknown', 12) : 'Production' table.addRow( runner.id, (runner.state ?? 'unknown').toUpperCase(), - getAgentName(agentInfo.get(runner.id) ?? 'unknown'), truncateText(runner.title ?? 'No title', 35), baseValue, runner.done_at ? formatDuration(runner.created_at, runner.done_at) : formatDuration(runner.created_at), @@ -135,26 +132,6 @@ export const agentsList = async (options: AgentListOptions, command: BaseCommand } } -const AGENT_LOOKUP_CONCURRENCY = 10 - -const fetchLatestAgentByRunner = async ( - api: ReturnType, - runners: AgentRunner[], -): Promise> => { - const result = new Map() - for (let start = 0; start < runners.length; start += AGENT_LOOKUP_CONCURRENCY) { - const batch = runners.slice(start, start + AGENT_LOOKUP_CONCURRENCY) - await Promise.allSettled( - batch.map(async (runner) => { - const sessions = await api.listAgentRunnerSessions(runner.id, { page: 1, per_page: 1 }) - const agent = sessions[0]?.agent_config?.agent - if (agent) result.set(runner.id, agent) - }), - ) - } - return result -} - const escapeRegex = (input: string): string => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const colorizeStatuses = (tableOutput: string, runners: AgentRunner[]): string => { diff --git a/tests/integration/commands/agents/agents-list.test.ts b/tests/integration/commands/agents/agents-list.test.ts index d75a4a3f55b..a5a28c15e83 100644 --- a/tests/integration/commands/agents/agents-list.test.ts +++ b/tests/integration/commands/agents/agents-list.test.ts @@ -3,13 +3,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' import { callCli } from '../../utils/call-cli.js' import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' import { withSiteBuilder } from '../../utils/site-builder.js' -import { - mockSiteInfo, - mockSiteInfoNoRepo, - mockAgentRunner, - mockAgentRunnerNoRepo, - mockAgentSession, -} from './fixtures.js' +import { mockSiteInfo, mockSiteInfoNoRepo, mockAgentRunner, mockAgentRunnerNoRepo } from './fixtures.js' // Mock spinner to avoid UI interference in tests vi.mock('../../../../src/lib/spinner.js', () => ({ @@ -37,11 +31,6 @@ describe('agents:list command', () => { method: 'GET' as const, response: [mockAgentRunner], }, - { - path: 'agent_runners/agent_runner_id/sessions', - method: 'GET' as const, - response: [mockAgentSession], - }, ] await withSiteBuilder(t, async (builder) => { @@ -53,7 +42,6 @@ describe('agents:list command', () => { expect(cliResponse).toContain('Agent Tasks for site-name') expect(cliResponse).toContain('agent_runner_id') expect(cliResponse).toContain('NEW') - expect(cliResponse).toContain('Claude') expect(cliResponse).toContain('Create a login form') }) }) @@ -114,11 +102,6 @@ describe('agents:list command', () => { method: 'GET' as const, response: [{ ...mockAgentRunner, state: 'running' }], }, - { - path: 'agent_runners/agent_runner_id/sessions', - method: 'GET' as const, - response: [mockAgentSession], - }, ] await withSiteBuilder(t, async (builder) => { @@ -182,10 +165,6 @@ describe('agents:list command', () => { path: 'agent_runners', response: [mockAgentRunnerNoRepo], }, - { - path: 'agent_runners/agent_runner_no_repo_id/sessions', - response: [mockAgentSession], - }, ] await withSiteBuilder(t, async (builder) => { @@ -212,10 +191,6 @@ describe('agents:list command', () => { path: 'agent_runners', response: [mockAgentRunner], }, - { - path: 'agent_runners/agent_runner_id/sessions', - response: [mockAgentSession], - }, ] await withSiteBuilder(t, async (builder) => { From c42005c9331d9403b3fc80777d32ba16a97ca2fe Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Mon, 11 May 2026 15:40:15 +0530 Subject: [PATCH 10/18] fix(agents): align CLI surface with React UI; add rename and sync --- docs/commands/agents.md | 112 +++++++++--- docs/index.md | 2 + src/commands/agents/agents-create.ts | 13 +- src/commands/agents/agents-follow-up.ts | 2 - src/commands/agents/agents-list.ts | 9 +- src/commands/agents/agents-open.ts | 15 +- src/commands/agents/agents-redeploy.ts | 2 +- src/commands/agents/agents-rename.ts | 40 +++++ src/commands/agents/agents-show.ts | 4 +- src/commands/agents/agents-sync.ts | 104 +++++++++++ src/commands/agents/agents.ts | 39 +++- src/commands/agents/api.ts | 16 ++ src/commands/agents/constants.ts | 5 +- src/commands/agents/types.ts | 7 +- src/commands/agents/utils.ts | 12 +- .../commands/agents/agents-list.test.ts | 15 +- .../commands/agents/agents-open.test.ts | 49 +++++ .../commands/agents/agents-rename.test.ts | 117 ++++++++++++ .../commands/agents/agents-sync.test.ts | 167 ++++++++++++++++++ tests/integration/utils/mock-api.ts | 5 +- 20 files changed, 662 insertions(+), 73 deletions(-) create mode 100644 src/commands/agents/agents-rename.ts create mode 100644 src/commands/agents/agents-sync.ts create mode 100644 tests/integration/commands/agents/agents-rename.test.ts create mode 100644 tests/integration/commands/agents/agents-sync.test.ts diff --git a/docs/commands/agents.md b/docs/commands/agents.md index 738574aff65..66504352efd 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -21,9 +21,9 @@ netlify agents **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -37,16 +37,18 @@ netlify agents | [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent task | | [`agents:publish`](/commands/agents#agentspublish) | Publish an agent task’s changes to production | | [`agents:redeploy`](/commands/agents#agentsredeploy) | Create a redeploy session that reapplies an existing diff (no AI inference) | +| [`agents:rename`](/commands/agents#agentsrename) | Rename an agent task | | [`agents:revert`](/commands/agents#agentsrevert) | Revert an agent task to a specific session (sessions after it are discarded) | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent task | | [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent task or session | +| [`agents:sync`](/commands/agents#agentssync) | Sync an agent task with the latest production code or remote git origin | **Examples** ```bash netlify agents:create --prompt "Add a contact form" -netlify agents:list --status live +netlify agents:list --status running netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a "Also add tests" netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a @@ -70,12 +72,12 @@ netlify agents:archive **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `yes` (*boolean*) - skip confirmation prompt -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -101,11 +103,11 @@ netlify agents:commit **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `branch` (*string*) - target branch to commit to - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `project` (*string*) - project ID or name (if not in a linked directory) **Examples** @@ -134,11 +136,9 @@ netlify agents:create - `agent` (*string*) - agent type (claude, codex, gemini) - `attach` (*string*) - attach a file or image (repeatable) - `branch` (*string*) - git branch to work on -- `dev-server-image` (*string*) - custom dev server Docker image - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `from-deploy` (*string*) - start the agent from a specific deploy (mutually exclusive with --branch) - `json` (*boolean*) - output result as JSON -- `mode` (*string*) - session mode (normal, create, ask) - `model` (*string*) - model to use for the agent - `parent` (*string*) - chain this agent task off of another agent task - `project` (*string*) - project ID or name (if not in a linked directory) @@ -154,7 +154,6 @@ netlify agents:create "Fix the login bug" netlify agents:create --prompt "Add dark mode" --agent claude netlify agents:create -p "Update README" -a codex -b feature-branch netlify agents:create "Triage this error" --attach error.log --attach screenshot.png -netlify agents:create "Tell me about this codebase" --mode ask ``` --- @@ -214,7 +213,6 @@ netlify agents:follow-up - `agent` (*string*) - override agent type for this session - `attach` (*string*) - attach a file or image (repeatable) -- `dev-server-image` (*string*) - custom dev server Docker image - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `model` (*string*) - override model for this session @@ -252,7 +250,7 @@ netlify agents:list - `per-page` (*string*) - items per page (max 100) - `project` (*string*) - project ID or name (if not in a linked directory) - `since` (*string*) - only show tasks created on or after this ISO timestamp -- `status` (*string*) - filter by status (live, error) +- `status` (*string*) - filter by status (running, done, error, archived) - `title` (*string*) - filter by title (case-insensitive contains) - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in @@ -263,7 +261,8 @@ netlify agents:list ```bash netlify agents:list -netlify agents:list --status live +netlify agents:list --status running +netlify agents:list --status archived netlify agents:list --branch main --since 2026-04-01 netlify agents:list --account my-team netlify agents:list --ndjson @@ -287,10 +286,10 @@ netlify agents:open **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `project` (*string*) - project ID or name (if not in a linked directory) - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `project` (*string*) - project ID or name (if not in a linked directory) **Examples** @@ -317,11 +316,11 @@ netlify agents:pr **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -346,12 +345,12 @@ netlify agents:publish **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `yes` (*boolean*) - skip confirmation prompt -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -377,12 +376,12 @@ netlify agents:redeploy **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `session` (*string*) - redeploy a specific session (defaults to the latest completed one) -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -391,6 +390,36 @@ netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a --session 70d8... ``` +--- +## `agents:rename` + +Rename an agent task + +**Usage** + +```bash +netlify agents:rename +``` + +**Arguments** + +- id - agent task ID +- title - new title for the agent task + +**Flags** + +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - output result as JSON +- `project` (*string*) - project ID or name (if not in a linked directory) + +**Examples** + +```bash +netlify agents:rename 60c7c3b3e7b4a0001f5e4b3a "Add dark mode toggle" +``` + --- ## `agents:revert` @@ -408,13 +437,13 @@ netlify agents:revert **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `session` (*string*) - session ID to revert to - `yes` (*boolean*) - skip confirmation prompt -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -439,13 +468,13 @@ netlify agents:show **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `session` (*string*) - show details of a specific session within the task - `watch` (*boolean*) - poll until the task reaches a terminal state -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -472,13 +501,13 @@ netlify agents:stop **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `session` (*string*) - stop a single session instead of the entire task - `yes` (*boolean*) - skip confirmation prompt -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -487,6 +516,37 @@ netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a --session 70d8... --yes ``` +--- +## `agents:sync` + +Sync an agent task with the latest production code or remote git origin + +**Usage** + +```bash +netlify agents:sync +``` + +**Arguments** + +- id - agent task ID + +**Flags** + +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - output result as JSON +- `project` (*string*) - project ID or name (if not in a linked directory) +- `yes` (*boolean*) - skip confirmation prompt + +**Examples** + +```bash +netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a +netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a --yes +``` + --- diff --git a/docs/index.md b/docs/index.md index 44f8acb5f50..bc4678c1e76 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,9 +34,11 @@ Manage Netlify AI agent tasks | [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent task | | [`agents:publish`](/commands/agents#agentspublish) | Publish an agent task’s changes to production | | [`agents:redeploy`](/commands/agents#agentsredeploy) | Create a redeploy session that reapplies an existing diff (no AI inference) | +| [`agents:rename`](/commands/agents#agentsrename) | Rename an agent task | | [`agents:revert`](/commands/agents#agentsrevert) | Revert an agent task to a specific session (sessions after it are discarded) | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent task | | [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent task or session | +| [`agents:sync`](/commands/agents#agentssync) | Sync an agent task with the latest production code or remote git origin | ### [api](/commands/api) diff --git a/src/commands/agents/agents-create.ts b/src/commands/agents/agents-create.ts index 767df943ea0..c1aced5bb00 100644 --- a/src/commands/agents/agents-create.ts +++ b/src/commands/agents/agents-create.ts @@ -7,7 +7,7 @@ import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpe import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' import { createAgentsApi } from './api.js' -import { AVAILABLE_AGENTS, type AvailableAgent, type UserSelectableMode } from './constants.js' +import { AVAILABLE_AGENTS, type AvailableAgent } from './constants.js' import { uploadAttachments, type UploadedAttachment } from './attachments.js' import type { CreateAgentRunnerPayload } from './types.js' import { @@ -16,7 +16,6 @@ import { formatStatus, getAgentName, validateAgent, - validateMode, validatePrompt, } from './utils.js' @@ -25,10 +24,8 @@ interface AgentCreateOptions extends OptionValues { agent?: string branch?: string model?: string - mode?: string fromDeploy?: string parent?: string - devServerImage?: string attach?: string[] json?: boolean } @@ -92,11 +89,6 @@ export const agentsCreate = async (promptArg: string, options: AgentCreateOption return logAndThrowError('Cannot attach files: no account ID is available for this site') } - if (options.mode) { - const valid = validateMode(options.mode) - if (valid !== true) return logAndThrowError(valid) - } - const finalPrompt = await resolvePrompt(promptArg, options.prompt, options) const agent = await resolveAgent(options.agent, options) @@ -136,8 +128,6 @@ export const agentsCreate = async (promptArg: string, options: AgentCreateOption branch, deploy_id: options.fromDeploy, parent_agent_runner_id: options.parent, - mode: options.mode as UserSelectableMode | undefined, - dev_server_image: options.devServerImage, file_keys: attachments.length > 0 ? attachments.map((entry) => entry.fileKey) : undefined, } @@ -157,7 +147,6 @@ export const agentsCreate = async (promptArg: string, options: AgentCreateOption log(` Task ID: ${chalk.cyan(agentRunner.id)}`) log(` Prompt: ${chalk.dim(finalPrompt)}`) log(` Agent: ${chalk.cyan(getAgentName(agent))}${options.model ? ` (${options.model})` : ''}`) - if (options.mode && options.mode !== 'normal') log(` Mode: ${chalk.cyan(options.mode)}`) if (options.fromDeploy) { log(` Base Deploy: ${chalk.cyan(options.fromDeploy)}`) } else if (isGitBased && branch) { diff --git a/src/commands/agents/agents-follow-up.ts b/src/commands/agents/agents-follow-up.ts index 5d4c17739f1..9f83f9e8177 100644 --- a/src/commands/agents/agents-follow-up.ts +++ b/src/commands/agents/agents-follow-up.ts @@ -21,7 +21,6 @@ interface AgentFollowUpOptions extends OptionValues { prompt?: string agent?: string model?: string - devServerImage?: string attach?: string[] json?: boolean } @@ -87,7 +86,6 @@ export const agentsFollowUp = async ( prompt: finalPrompt, agent, model: options.model, - dev_server_image: options.devServerImage, file_keys: attachments.length > 0 ? attachments.map((entry) => entry.fileKey) : undefined, } diff --git a/src/commands/agents/agents-list.ts b/src/commands/agents/agents-list.ts index f1461ef4ce0..4b71c3e1eed 100644 --- a/src/commands/agents/agents-list.ts +++ b/src/commands/agents/agents-list.ts @@ -6,7 +6,7 @@ import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' import { createAgentsApi } from './api.js' import type { AgentRunner, ListAgentRunnersFilters } from './types.js' -import { formatDuration, formatStatus, truncateText } from './utils.js' +import { formatDuration, formatStatus, isListStatusFilter, truncateText, validateListStatusFilter } from './utils.js' interface AgentListOptions extends OptionValues { status?: string @@ -42,10 +42,9 @@ const parsePositiveInt = (input: string | undefined, name: string): number | und const buildFilters = (options: AgentListOptions): ListAgentRunnersFilters => { const filters: ListAgentRunnersFilters = {} if (options.status) { - if (options.status !== 'live' && options.status !== 'error') { - throw new Error('--status accepts only "live" or "error"') - } - filters.state = options.status + const valid = validateListStatusFilter(options.status) + if (valid !== true) throw new Error(valid) + if (isListStatusFilter(options.status)) filters.state = options.status } if (options.branch) filters.branch = options.branch if (options.user) filters.user_id = options.user diff --git a/src/commands/agents/agents-open.ts b/src/commands/agents/agents-open.ts index 2e2577e9016..d31a5ab0d83 100644 --- a/src/commands/agents/agents-open.ts +++ b/src/commands/agents/agents-open.ts @@ -50,12 +50,19 @@ export const agentsOpen = async ( } if (target === 'pr') { - if (!runner.pr_url) { - log(chalk.yellow('No pull request exists for this agent task.')) - log(`Create one with: ${chalk.cyan(`netlify agents:pr ${id}`)}`) + if (runner.pr_url) return openUrl(runner.pr_url) + if (runner.pr_is_being_created) { + log(chalk.yellow('A pull request is being created. Try again in a moment.')) return } - return openUrl(runner.pr_url) + if (runner.pr_error) { + log(chalk.red(`Pull request creation failed: ${runner.pr_error}`)) + log(`Retry with: ${chalk.cyan(`netlify agents:pr ${id}`)}`) + return + } + log(chalk.yellow('No pull request exists for this agent task.')) + log(`Create one with: ${chalk.cyan(`netlify agents:pr ${id}`)}`) + return } const previewUrl = runner.latest_session_deploy_url diff --git a/src/commands/agents/agents-redeploy.ts b/src/commands/agents/agents-redeploy.ts index 32a13f1182f..0a73ca1fa66 100644 --- a/src/commands/agents/agents-redeploy.ts +++ b/src/commands/agents/agents-redeploy.ts @@ -25,7 +25,7 @@ export const agentsRedeploy = async (id: string, options: AgentRedeployOptions, let page = 1 let latestDone: { id: string } | undefined while (!latestDone && page <= maxPages) { - const sessions = await api.listAgentRunnerSessions(id, { page, per_page: perPage }) + const sessions = await api.listAgentRunnerSessions(id, { page, per_page: perPage, order_by: 'desc' }) latestDone = sessions.find((session) => session.state === 'done') if (latestDone || sessions.length < perPage) break page += 1 diff --git a/src/commands/agents/agents-rename.ts b/src/commands/agents/agents-rename.ts new file mode 100644 index 00000000000..5f20f6a2ee6 --- /dev/null +++ b/src/commands/agents/agents-rename.ts @@ -0,0 +1,40 @@ +import type { OptionValues } from 'commander' + +import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' + +interface AgentRenameOptions extends OptionValues { + json?: boolean +} + +export const agentsRename = async (id: string, title: string, options: AgentRenameOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + const trimmed = title.trim() + if (!trimmed) return logAndThrowError('A non-empty title is required') + + await command.authenticate() + const api = createAgentsApi(command.netlify) + + const spinner = startSpinner({ text: 'Renaming agent task...' }) + try { + const runner = await api.renameAgentRunner(id, trimmed) + stopSpinner({ spinner }) + + if (options.json) { + logJson(runner) + return runner + } + + log(`${chalk.green('✓')} Agent task renamed.`) + log(` Task ID: ${chalk.cyan(runner.id)}`) + log(` Title: ${chalk.cyan(runner.title ?? trimmed)}`) + return runner + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to rename: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-show.ts b/src/commands/agents/agents-show.ts index 575279355cc..dc50126de91 100644 --- a/src/commands/agents/agents-show.ts +++ b/src/commands/agents/agents-show.ts @@ -87,7 +87,7 @@ const showAgentTask = async (api: AgentsApi, id: string, options: AgentShowOptio try { const [runner, sessions] = await Promise.all([ api.getAgentRunner(id), - api.listAgentRunnerSessions(id, { page: 1, per_page: 100 }), + api.listAgentRunnerSessions(id, { page: 1, per_page: 100, order_by: 'desc' }), ]) stopSpinner({ spinner }) @@ -368,7 +368,7 @@ const watchAgentTask = async (api: AgentsApi, id: string, command: BaseCommand) try { ;[lastRunner, lastSessions] = await Promise.all([ api.getAgentRunner(id), - api.listAgentRunnerSessions(id, { page: 1, per_page: 100 }), + api.listAgentRunnerSessions(id, { page: 1, per_page: 100, order_by: 'desc' }), ]) } catch (error_) { const error = error_ as Error diff --git a/src/commands/agents/agents-sync.ts b/src/commands/agents/agents-sync.ts new file mode 100644 index 00000000000..3ffc4109148 --- /dev/null +++ b/src/commands/agents/agents-sync.ts @@ -0,0 +1,104 @@ +import type { OptionValues } from 'commander' +import inquirer from 'inquirer' + +import { chalk, exit, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi, type AgentsApi } from './api.js' +import type { AgentRunner } from './types.js' + +interface AgentSyncOptions extends OptionValues { + json?: boolean + yes?: boolean +} + +type SyncStrategy = 'sync_git_origin' | 'merge_target' | 'rebase' + +const pickStrategy = (runner: AgentRunner): SyncStrategy | null => { + if (runner.needs_git_sync) return 'sync_git_origin' + if (runner.merge_target_available) return 'merge_target' + if (runner.rebase_available) return 'rebase' + return null +} + +const describeStrategy = (strategy: SyncStrategy): string => { + switch (strategy) { + case 'sync_git_origin': + return 'sync with the remote git origin (code origin changed)' + case 'merge_target': + return 'merge the latest target branch into this agent run' + case 'rebase': + return 'reapply changes on top of the latest production deploy' + } +} + +const runStrategy = (api: AgentsApi, strategy: SyncStrategy, id: string): Promise => { + switch (strategy) { + case 'sync_git_origin': + return api.syncGitOriginAgentRunner(id) + case 'merge_target': + return api.mergeTargetAgentRunner(id) + case 'rebase': + return api.rebaseAgentRunner(id) + } +} + +export const agentsSync = async (id: string, options: AgentSyncOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + await command.authenticate() + const api = createAgentsApi(command.netlify) + + const lookupSpinner = startSpinner({ text: 'Checking agent task state...' }) + let runner: AgentRunner + try { + runner = await api.getAgentRunner(id) + stopSpinner({ spinner: lookupSpinner }) + } catch (error_) { + stopSpinner({ spinner: lookupSpinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to fetch agent task: ${error.message}`) + } + + const strategy = pickStrategy(runner) + if (!strategy) { + log(chalk.yellow('Nothing to sync — this agent task is already up to date.')) + return runner + } + + if (!options.yes && !options.json) { + if (!process.stdin.isTTY) { + return logAndThrowError('Refusing to sync without --yes when stdin is not a TTY') + } + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message: `Sync agent task ${id}? This will ${describeStrategy(strategy)}.`, + default: false, + }, + ]) + if (!confirmed) return exit() + } + + const spinner = startSpinner({ text: 'Syncing agent task...' }) + try { + const updated = await runStrategy(api, strategy, id) + stopSpinner({ spinner }) + + if (options.json) { + logJson(updated) + return updated + } + + log(`${chalk.green('✓')} Sync started: ${describeStrategy(strategy)}.`) + log(` Task ID: ${chalk.cyan(updated.id)}`) + log() + log(`Watch progress: ${chalk.cyan(`netlify agents:show ${updated.id} --watch`)}`) + return updated + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to sync: ${error.message}`) + } +} diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index 0c5c73c9736..b5d2b031ed5 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -22,8 +22,6 @@ export const createAgentsCommand = (program: BaseCommand) => { .option('-b, --branch ', 'git branch to work on') .option('--from-deploy ', 'start the agent from a specific deploy (mutually exclusive with --branch)') .option('--parent ', 'chain this agent task off of another agent task') - .option('--mode ', 'session mode (normal, create, ask)') - .option('--dev-server-image ', 'custom dev server Docker image') .option('--attach ', 'attach a file or image (repeatable)', collect, []) .option('--project ', 'project ID or name (if not in a linked directory)') .option('--json', 'output result as JSON') @@ -34,7 +32,6 @@ export const createAgentsCommand = (program: BaseCommand) => { 'netlify agents:create --prompt "Add dark mode" --agent claude', 'netlify agents:create -p "Update README" -a codex -b feature-branch', 'netlify agents:create "Triage this error" --attach error.log --attach screenshot.png', - 'netlify agents:create "Tell me about this codebase" --mode ask', ]) .action(async (prompt: string, options: OptionValues, command: BaseCommand) => { const { agentsCreate } = await import('./agents-create.js') @@ -49,7 +46,6 @@ export const createAgentsCommand = (program: BaseCommand) => { .option('-p, --prompt ', 'follow-up prompt') .option('-a, --agent ', 'override agent type for this session') .option('-m, --model ', 'override model for this session') - .option('--dev-server-image ', 'custom dev server Docker image') .option('--attach ', 'attach a file or image (repeatable)', collect, []) .option('--project ', 'project ID or name (if not in a linked directory)') .option('--json', 'output result as JSON') @@ -66,7 +62,7 @@ export const createAgentsCommand = (program: BaseCommand) => { program .command('agents:list') .description('List agent tasks for the current site') - .option('-s, --status ', 'filter by status (live, error)') + .option('-s, --status ', 'filter by status (running, done, error, archived)') .option('-b, --branch ', 'filter by branch') .option('-u, --user ', 'filter by user ID') .option('-t, --title ', 'filter by title (case-insensitive contains)') @@ -81,7 +77,8 @@ export const createAgentsCommand = (program: BaseCommand) => { .hook('preAction', requiresSiteInfoWithProject) .addExamples([ 'netlify agents:list', - 'netlify agents:list --status live', + 'netlify agents:list --status running', + 'netlify agents:list --status archived', 'netlify agents:list --branch main --since 2026-04-01', 'netlify agents:list --account my-team', 'netlify agents:list --ndjson', @@ -261,6 +258,34 @@ export const createAgentsCommand = (program: BaseCommand) => { await agentsRedeploy(id, options, command) }) + program + .command('agents:rename') + .argument('', 'agent task ID') + .argument('', 'new title for the agent task') + .description('Rename an agent task') + .option('--json', 'output result as JSON') + .option('--project <project>', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples(['netlify agents:rename 60c7c3b3e7b4a0001f5e4b3a "Add dark mode toggle"']) + .action(async (id: string, title: string, options: OptionValues, command: BaseCommand) => { + const { agentsRename } = await import('./agents-rename.js') + await agentsRename(id, title, options, command) + }) + + program + .command('agents:sync') + .argument('<id>', 'agent task ID') + .description('Sync an agent task with the latest production code or remote git origin') + .option('-y, --yes', 'skip confirmation prompt') + .option('--json', 'output result as JSON') + .option('--project <project>', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples(['netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a', 'netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a --yes']) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsSync } = await import('./agents-sync.js') + await agentsSync(id, options, command) + }) + const name = chalk.greenBright('`agents`') return program @@ -273,7 +298,7 @@ Note: Agent tasks execute remotely on Netlify infrastructure, not locally.`, ) .addExamples([ 'netlify agents:create --prompt "Add a contact form"', - 'netlify agents:list --status live', + 'netlify agents:list --status running', 'netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch', 'netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a "Also add tests"', 'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a', diff --git a/src/commands/agents/api.ts b/src/commands/agents/api.ts index 070dd987dd6..0e3c0883079 100644 --- a/src/commands/agents/api.ts +++ b/src/commands/agents/api.ts @@ -190,6 +190,18 @@ export const createAgentsApi = (netlify: NetlifyContext) => { const agentRunnerRevert = (id: string, sessionId: string): Promise<AgentRunner> => requestJson<AgentRunner>(`/agent_runners/${id}/revert`, jsonInit('POST', { session_id: sessionId })) + const renameAgentRunner = (id: string, title: string): Promise<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}`, jsonInit('PATCH', { title })) + + const rebaseAgentRunner = (id: string): Promise<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}/rebase`, jsonInit('POST')) + + const mergeTargetAgentRunner = (id: string): Promise<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}/merge_target`, jsonInit('POST')) + + const syncGitOriginAgentRunner = (id: string): Promise<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}/sync_git_origin`, jsonInit('POST')) + const createUploadUrl = (payload: { account_id: string filename: string @@ -230,6 +242,10 @@ export const createAgentsApi = (netlify: NetlifyContext) => { agentRunnerCommitToBranch, agentRunnerPublishToProduction, agentRunnerRevert, + renameAgentRunner, + rebaseAgentRunner, + mergeTargetAgentRunner, + syncGitOriginAgentRunner, createUploadUrl, createDeleteUrl, listAiGatewayProviders, diff --git a/src/commands/agents/constants.ts b/src/commands/agents/constants.ts index 32240da3fed..8ab3e921351 100644 --- a/src/commands/agents/constants.ts +++ b/src/commands/agents/constants.ts @@ -24,7 +24,8 @@ export const SESSION_MODES = [ 'ask', 'conflict_resolution', ] as const -export const USER_SELECTABLE_MODES = ['normal', 'create', 'ask'] as const + +export const LIST_STATUS_FILTERS = ['running', 'done', 'error', 'archived'] as const export const STATUS_COLORS = { new: chalk.blue, @@ -43,5 +44,5 @@ export const MAX_ATTACHMENT_SIZE_BYTES = 10 * 1024 * 1024 export type AgentState = (typeof AGENT_STATES)[number] export type SessionState = (typeof SESSION_STATES)[number] export type SessionMode = (typeof SESSION_MODES)[number] -export type UserSelectableMode = (typeof USER_SELECTABLE_MODES)[number] +export type ListStatusFilter = (typeof LIST_STATUS_FILTERS)[number] export type AvailableAgent = (typeof AVAILABLE_AGENTS)[number]['value'] diff --git a/src/commands/agents/types.ts b/src/commands/agents/types.ts index c7276a3df12..67608298d1e 100644 --- a/src/commands/agents/types.ts +++ b/src/commands/agents/types.ts @@ -1,4 +1,4 @@ -import type { AgentState, SessionState, SessionMode, AvailableAgent } from './constants.js' +import type { AgentState, ListStatusFilter, SessionState, SessionMode, AvailableAgent } from './constants.js' export interface AgentConfig { agent?: AvailableAgent @@ -116,8 +116,6 @@ export interface CreateAgentRunnerPayload { branch?: string deploy_id?: string parent_agent_runner_id?: string - mode?: SessionMode - dev_server_image?: string file_keys?: string[] } @@ -125,12 +123,11 @@ export interface CreateAgentRunnerSessionPayload { prompt: string agent?: AvailableAgent model?: string - dev_server_image?: string file_keys?: string[] } export interface ListAgentRunnersFilters { - state?: 'live' | 'error' + state?: ListStatusFilter branch?: string result_branch?: string user_id?: string diff --git a/src/commands/agents/utils.ts b/src/commands/agents/utils.ts index 97e874b692b..c22dd3f2eb0 100644 --- a/src/commands/agents/utils.ts +++ b/src/commands/agents/utils.ts @@ -1,7 +1,8 @@ import path from 'path' import { chalk } from '../../utils/command-helpers.js' -import { AGENT_TO_PROVIDER, AVAILABLE_AGENTS, STATUS_COLORS, USER_SELECTABLE_MODES } from './constants.js' +import { AGENT_TO_PROVIDER, AVAILABLE_AGENTS, LIST_STATUS_FILTERS, STATUS_COLORS } from './constants.js' +import type { ListStatusFilter } from './constants.js' import type { AgentsApi } from './api.js' import type { AvailableAgent } from './constants.js' import type { AgentRunnerSessionUsage } from './types.js' @@ -56,11 +57,14 @@ export const validateAgent = (agent: string): true | string => { return true } -export const validateMode = (mode: string): true | string => { - if ((USER_SELECTABLE_MODES as readonly string[]).includes(mode)) return true - return `Invalid mode. Available modes: ${USER_SELECTABLE_MODES.join(', ')}` +export const validateListStatusFilter = (status: string): true | string => { + if ((LIST_STATUS_FILTERS as readonly string[]).includes(status)) return true + return `--status accepts only ${LIST_STATUS_FILTERS.map((entry) => `"${entry}"`).join(', ')}` } +export const isListStatusFilter = (status: string): status is ListStatusFilter => + (LIST_STATUS_FILTERS as readonly string[]).includes(status) + export const checkModelAvailability = async ( api: AgentsApi, agent: AvailableAgent, diff --git a/tests/integration/commands/agents/agents-list.test.ts b/tests/integration/commands/agents/agents-list.test.ts index a5a28c15e83..c0f7ce4eb4b 100644 --- a/tests/integration/commands/agents/agents-list.test.ts +++ b/tests/integration/commands/agents/agents-list.test.ts @@ -109,18 +109,31 @@ describe('agents:list command', () => { await withMockApi(routes, async ({ apiUrl, requests }) => { const cliResponse = (await callCli( - ['agents:list', '--status', 'live'], + ['agents:list', '--status', 'running'], getCLIOptions({ apiUrl, builder }), )) as string const agentRequest = requests.find((r) => r.path.includes('agent_runners')) expect(agentRequest).toBeDefined() + expect(agentRequest?.originalUrl).toContain('state=running') expect(cliResponse).toContain('RUNNING') }) }) }) + test('should reject unsupported status values', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:list', '--status', 'live'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + '--status accepts only "running", "done", "error", "archived"', + ) + }) + }) + }) + test('should handle authentication errors', async (t) => { const routes = [ ...baseRoutes, diff --git a/tests/integration/commands/agents/agents-open.test.ts b/tests/integration/commands/agents/agents-open.test.ts index ddd726b8933..ab31078559b 100644 --- a/tests/integration/commands/agents/agents-open.test.ts +++ b/tests/integration/commands/agents/agents-open.test.ts @@ -140,6 +140,55 @@ describe('agents:open command', () => { }) }) + test('should explain when a PR is being created', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: { ...mockAgentRunner, pr_is_being_created: true }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id', 'pr'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('A pull request is being created') + }) + }) + }) + + test('should surface PR creation errors', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: { ...mockAgentRunner, pr_error: 'Repository not connected' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id', 'pr'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('Pull request creation failed: Repository not connected') + expect(cliResponse).toContain('netlify agents:pr test_id') + }) + }) + }) + test('should reject invalid targets', async (t) => { await withSiteBuilder(t, async (builder) => { await builder.build() diff --git a/tests/integration/commands/agents/agents-rename.test.ts b/tests/integration/commands/agents/agents-rename.test.ts new file mode 100644 index 00000000000..40eac331b0a --- /dev/null +++ b/tests/integration/commands/agents/agents-rename.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentRunner } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:rename command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should rename an agent task', async (t) => { + const renamed = { ...mockAgentRunner, title: 'New title' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'PATCH' as const, + response: renamed, + validateRequest: (request: { body: string }) => { + const body = JSON.parse(request.body) as { title: string } + expect(body.title).toBe('New title') + }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:rename', 'test_id', 'New title'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Agent task renamed.') + expect(cliResponse).toContain('Title: New title') + }) + }) + }) + + test('should trim whitespace from the title', async (t) => { + const renamed = { ...mockAgentRunner, title: 'Trimmed title' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'PATCH' as const, + response: renamed, + validateRequest: (request: { body: string }) => { + const body = JSON.parse(request.body) as { title: string } + expect(body.title).toBe('Trimmed title') + }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:rename', 'test_id', ' Trimmed title '], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Title: Trimmed title') + }) + }) + }) + + test('should reject empty titles', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:rename', 'test_id', ' '], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'A non-empty title is required', + ) + }) + }) + }) + + test('should surface 404 when the task is missing', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/missing_id', + method: 'PATCH' as const, + status: 404, + response: { error: 'Not found' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:rename', 'missing_id', 'Title'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Agent task not found: missing_id') + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-sync.test.ts b/tests/integration/commands/agents/agents-sync.test.ts new file mode 100644 index 00000000000..bb3db42224f --- /dev/null +++ b/tests/integration/commands/agents/agents-sync.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentRunner } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:sync command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should rebase when only rebase is available', async (t) => { + const runner = { ...mockAgentRunner, rebase_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runner, + }, + { + path: 'agent_runners/test_id/rebase', + method: 'POST' as const, + response: runner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:sync', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Sync started') + expect(cliResponse).toContain('reapply changes on top of the latest production deploy') + const syncRequest = requests.find((r) => r.path.endsWith('/rebase')) + expect(syncRequest).toBeDefined() + }) + }) + }) + + test('should merge target when merge_target is available', async (t) => { + const runner = { ...mockAgentRunner, merge_target_available: true, rebase_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runner, + }, + { + path: 'agent_runners/test_id/merge_target', + method: 'POST' as const, + response: runner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:sync', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('merge the latest target branch') + const mergeRequest = requests.find((r) => r.path.endsWith('/merge_target')) + expect(mergeRequest).toBeDefined() + }) + }) + }) + + test('should sync git origin when needs_git_sync is set', async (t) => { + const runner = { ...mockAgentRunner, needs_git_sync: true, rebase_available: true, merge_target_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runner, + }, + { + path: 'agent_runners/test_id/sync_git_origin', + method: 'POST' as const, + response: runner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:sync', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('sync with the remote git origin') + const syncRequest = requests.find((r) => r.path.endsWith('/sync_git_origin')) + expect(syncRequest).toBeDefined() + }) + }) + }) + + test('should report when nothing needs syncing', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: mockAgentRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:sync', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Nothing to sync') + }) + }) + }) + + test('should surface 404 when the task is missing', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/missing_id', + method: 'GET' as const, + status: 404, + response: { error: 'Not found' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:sync', 'missing_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Agent task not found: missing_id') + }) + }) + }) +}) diff --git a/tests/integration/utils/mock-api.ts b/tests/integration/utils/mock-api.ts index 9da0d11d28b..24ae081ce0c 100644 --- a/tests/integration/utils/mock-api.ts +++ b/tests/integration/utils/mock-api.ts @@ -21,7 +21,7 @@ interface MockApiOptions { export interface MockApi { apiUrl: string - requests: { path: string; body: unknown; method: string; headers: IncomingHttpHeaders }[] + requests: { path: string; originalUrl: string; body: unknown; method: string; headers: IncomingHttpHeaders }[] server: Server close: () => Promise<void> } @@ -34,6 +34,7 @@ export interface MockApiTestContext { const addRequest = (requests: MockApi['requests'], request: express.Request) => { requests.push({ path: request.path, + originalUrl: request.originalUrl, body: request.body, method: request.method, headers: request.headers, @@ -133,7 +134,7 @@ export const withMockApi = async ( routes: Route[], testHandler: (options: { apiUrl: string - requests: { path: string; body: unknown; method: string; headers: IncomingHttpHeaders }[] + requests: { path: string; originalUrl: string; body: unknown; method: string; headers: IncomingHttpHeaders }[] }) => Promise<void>, silent = false, ) => { From afd8f61c5cef667479960b7ae2dec7ffadf13650 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya <vaibhavacharya111@gmail.com> Date: Mon, 11 May 2026 15:57:00 +0530 Subject: [PATCH 11/18] fix(agents): match deeper UI behavior (publish guard, title sanitization, default branches, sync hints) --- src/commands/agents/agents-commit.ts | 56 ++++++++--- src/commands/agents/agents-create.ts | 3 +- src/commands/agents/agents-follow-up.ts | 3 +- src/commands/agents/agents-publish.ts | 75 +++++++++++++-- src/commands/agents/agents-rename.ts | 10 +- src/commands/agents/agents-show.ts | 15 ++- src/commands/agents/agents.ts | 2 + src/commands/agents/utils.ts | 17 ++++ .../commands/agents/agents-commit.test.ts | 57 +++++++++++- .../commands/agents/agents-publish.test.ts | 93 ++++++++++++++++++- .../commands/agents/agents-rename.test.ts | 43 +++++++++ .../commands/agents/agents-show.test.ts | 2 +- 12 files changed, 339 insertions(+), 37 deletions(-) diff --git a/src/commands/agents/agents-commit.ts b/src/commands/agents/agents-commit.ts index 164e24e7ad8..ec997f77ef5 100644 --- a/src/commands/agents/agents-commit.ts +++ b/src/commands/agents/agents-commit.ts @@ -5,31 +5,65 @@ import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpe import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' import { createAgentsApi } from './api.js' +import type { AgentRunner } from './types.js' interface AgentCommitOptions extends OptionValues { branch?: string json?: boolean } +const pickDefaultBranch = (runner: AgentRunner): { branch: string; reason: string } | null => { + const prState = runner.pr_state + const hasOpenPr = runner.pr_url && (prState === 'open' || prState === 'draft') + if (hasOpenPr && runner.pr_branch) { + return { branch: runner.pr_branch, reason: 'updating the existing pull request' } + } + if (runner.branch) { + return { branch: runner.branch, reason: "committing to this agent task's branch" } + } + return null +} + export const agentsCommit = async (id: string, options: AgentCommitOptions, command: BaseCommand) => { if (!id) return logAndThrowError('Agent task ID is required') await command.authenticate() const api = createAgentsApi(command.netlify) let targetBranch = options.branch?.trim() + if (!targetBranch) { - if (!process.stdin.isTTY) { - return logAndThrowError('--branch is required when stdin is not a TTY') + const lookupSpinner = startSpinner({ text: 'Looking up agent task...' }) + let runner: AgentRunner + try { + runner = await api.getAgentRunner(id) + stopSpinner({ spinner: lookupSpinner }) + } catch (error_) { + stopSpinner({ spinner: lookupSpinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to fetch agent task: ${error.message}`) + } + + const suggestion = pickDefaultBranch(runner) + if (!suggestion) { + return logAndThrowError('Could not determine a target branch. Pass --branch <name>.') + } + + if (options.json || !process.stdin.isTTY) { + targetBranch = suggestion.branch + } else { + log(chalk.dim(`Default: ${suggestion.branch} (${suggestion.reason})`)) + const { branchInput } = await inquirer.prompt<{ branchInput: string }>([ + { + type: 'input', + name: 'branchInput', + message: 'Which branch should the agent commit to?', + default: suggestion.branch, + validate: (input: string) => (input.trim().length > 0 ? true : 'Branch name is required'), + }, + ]) + targetBranch = branchInput.trim() } - const { branchInput } = await inquirer.prompt<{ branchInput: string }>([ - { - type: 'input', - name: 'branchInput', - message: 'Which branch should the agent commit to?', - validate: (input: string) => (input.trim().length > 0 ? true : 'Branch name is required'), - }, - ]) - targetBranch = branchInput.trim() } const spinner = startSpinner({ text: `Committing to ${targetBranch}...` }) diff --git a/src/commands/agents/agents-create.ts b/src/commands/agents/agents-create.ts index c1aced5bb00..569ef1850ef 100644 --- a/src/commands/agents/agents-create.ts +++ b/src/commands/agents/agents-create.ts @@ -15,6 +15,7 @@ import { formatBytes, formatStatus, getAgentName, + sanitizePromptText, validateAgent, validatePrompt, } from './utils.js' @@ -122,7 +123,7 @@ export const agentsCreate = async (promptArg: string, options: AgentCreateOption } const payload: CreateAgentRunnerPayload = { - prompt: finalPrompt, + prompt: sanitizePromptText(finalPrompt), agent, model: options.model, branch, diff --git a/src/commands/agents/agents-follow-up.ts b/src/commands/agents/agents-follow-up.ts index 9f83f9e8177..85fe3c89035 100644 --- a/src/commands/agents/agents-follow-up.ts +++ b/src/commands/agents/agents-follow-up.ts @@ -13,6 +13,7 @@ import { formatBytes, formatStatus, getAgentName, + sanitizePromptText, validateAgent, validatePrompt, } from './utils.js' @@ -83,7 +84,7 @@ export const agentsFollowUp = async ( } const payload: CreateAgentRunnerSessionPayload = { - prompt: finalPrompt, + prompt: sanitizePromptText(finalPrompt), agent, model: options.model, file_keys: attachments.length > 0 ? attachments.map((entry) => entry.fileKey) : undefined, diff --git a/src/commands/agents/agents-publish.ts b/src/commands/agents/agents-publish.ts index 17b2c8e2449..fe7732e2b16 100644 --- a/src/commands/agents/agents-publish.ts +++ b/src/commands/agents/agents-publish.ts @@ -5,10 +5,21 @@ import { chalk, exit, log, logAndThrowError, logJson } from '../../utils/command import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' import { createAgentsApi } from './api.js' +import type { AgentRunner } from './types.js' interface AgentPublishOptions extends OptionValues { json?: boolean yes?: boolean + force?: boolean +} + +const isOutOfSync = (runner: AgentRunner): boolean => + Boolean(runner.needs_git_sync || runner.rebase_available || runner.merge_target_available) + +const describeOutOfSync = (runner: AgentRunner): string => { + if (runner.needs_git_sync) return 'the code origin has changed since this run started' + if (runner.merge_target_available) return 'the target branch has new commits' + return 'production has moved on since this run started' } export const agentsPublish = async (id: string, options: AgentPublishOptions, command: BaseCommand) => { @@ -17,7 +28,55 @@ export const agentsPublish = async (id: string, options: AgentPublishOptions, co const { siteInfo } = command.netlify const api = createAgentsApi(command.netlify) - if (!options.yes && !options.json) { + const lookupSpinner = startSpinner({ text: 'Checking agent task state...' }) + let runner: AgentRunner + try { + runner = await api.getAgentRunner(id) + stopSpinner({ spinner: lookupSpinner }) + } catch (error_) { + stopSpinner({ spinner: lookupSpinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to fetch agent task: ${error.message}`) + } + + const outOfSync = isOutOfSync(runner) + if (outOfSync && !options.force) { + if (options.json) { + return logAndThrowError( + `Refusing to publish: ${describeOutOfSync(runner)}. Run netlify agents:sync ${id} first, or pass --force.`, + ) + } + log(chalk.yellow(`! This agent task is out of date: ${describeOutOfSync(runner)}.`)) + log(` Sync first: ${chalk.cyan(`netlify agents:sync ${id}`)}`) + log(` Or override: pass ${chalk.cyan('--force')} to publish the existing diff as-is`) + if (!options.yes) { + if (!process.stdin.isTTY) return logAndThrowError('Refusing to publish out-of-date run without --force') + const { action } = await inquirer.prompt<{ action: 'sync' | 'publish' | 'cancel' }>([ + { + type: 'list', + name: 'action', + message: 'How would you like to proceed?', + choices: [ + { name: 'Sync with production first (recommended)', value: 'sync' }, + { name: 'Publish anyway', value: 'publish' }, + { name: 'Cancel', value: 'cancel' }, + ], + default: 'sync', + }, + ]) + if (action === 'cancel') return exit() + if (action === 'sync') { + const { agentsSync } = await import('./agents-sync.js') + return agentsSync(id, { yes: true }, command) + } + // action === 'publish' falls through + } else { + return logAndThrowError('Refusing to publish out-of-date run without --force') + } + } + + if (!options.yes && !options.json && !outOfSync) { if (!process.stdin.isTTY) { return logAndThrowError('Refusing to publish without --yes when stdin is not a TTY') } @@ -38,20 +97,20 @@ export const agentsPublish = async (id: string, options: AgentPublishOptions, co const spinner = startSpinner({ text: 'Publishing to production...' }) try { - const runner = await api.agentRunnerPublishToProduction(id) + const updated = await api.agentRunnerPublishToProduction(id) stopSpinner({ spinner }) if (options.json) { - logJson(runner) - return runner + logJson(updated) + return updated } log(`${chalk.green('✓')} Published agent task to production!`) log() - log(` Task ID: ${chalk.cyan(runner.id)}`) - if (runner.merge_commit_sha) log(` Commit: ${chalk.cyan(runner.merge_commit_sha)}`) - log(` Browser: ${chalk.blue(`https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${runner.id}`)}`) - return runner + log(` Task ID: ${chalk.cyan(updated.id)}`) + if (updated.merge_commit_sha) log(` Commit: ${chalk.cyan(updated.merge_commit_sha)}`) + log(` Browser: ${chalk.blue(`https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${updated.id}`)}`) + return updated } catch (error_) { stopSpinner({ spinner, error: true }) const error = error_ as Error diff --git a/src/commands/agents/agents-rename.ts b/src/commands/agents/agents-rename.ts index 5f20f6a2ee6..3a805530d7e 100644 --- a/src/commands/agents/agents-rename.ts +++ b/src/commands/agents/agents-rename.ts @@ -4,6 +4,7 @@ import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpe import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' import { createAgentsApi } from './api.js' +import { sanitizeRunnerTitle, validateRunnerTitle } from './utils.js' interface AgentRenameOptions extends OptionValues { json?: boolean @@ -11,15 +12,16 @@ interface AgentRenameOptions extends OptionValues { export const agentsRename = async (id: string, title: string, options: AgentRenameOptions, command: BaseCommand) => { if (!id) return logAndThrowError('Agent task ID is required') - const trimmed = title.trim() - if (!trimmed) return logAndThrowError('A non-empty title is required') + const valid = validateRunnerTitle(title) + if (valid !== true) return logAndThrowError(valid) + const sanitized = sanitizeRunnerTitle(title) await command.authenticate() const api = createAgentsApi(command.netlify) const spinner = startSpinner({ text: 'Renaming agent task...' }) try { - const runner = await api.renameAgentRunner(id, trimmed) + const runner = await api.renameAgentRunner(id, sanitized) stopSpinner({ spinner }) if (options.json) { @@ -29,7 +31,7 @@ export const agentsRename = async (id: string, title: string, options: AgentRena log(`${chalk.green('✓')} Agent task renamed.`) log(` Task ID: ${chalk.cyan(runner.id)}`) - log(` Title: ${chalk.cyan(runner.title ?? trimmed)}`) + log(` Title: ${chalk.cyan(runner.title ?? sanitized)}`) return runner } catch (error_) { stopSpinner({ spinner, error: true }) diff --git a/src/commands/agents/agents-show.ts b/src/commands/agents/agents-show.ts index dc50126de91..a1d55693c61 100644 --- a/src/commands/agents/agents-show.ts +++ b/src/commands/agents/agents-show.ts @@ -168,7 +168,7 @@ const renderAgentTask = (runner: AgentRunner, sessions: AgentRunnerSession[], co log() log(chalk.bold('Task:')) - log(` Prompt: ${chalk.dim(runner.title ?? 'No title')}`) + log(` Title: ${chalk.dim(runner.title ?? 'No title')}`) if (runner.current_task) log(` Current Task: ${chalk.yellow(runner.current_task)}`) log() @@ -206,6 +206,19 @@ const renderAgentTask = (runner: AgentRunner, sessions: AgentRunnerSession[], co if (runner.merge_commit_error) log(` ${chalk.red('Error:')} ${runner.merge_commit_error}`) } + if (runner.needs_git_sync || runner.rebase_available || runner.merge_target_available) { + log() + log(chalk.bold('Sync needed:')) + if (runner.needs_git_sync) { + log(` ${chalk.yellow('!')} Code origin changed since this run started.`) + } else if (runner.merge_target_available) { + log(` ${chalk.yellow('!')} The target branch has new commits.`) + } else if (runner.rebase_available) { + log(` ${chalk.yellow('!')} Production has moved on since this run started.`) + } + log(` Run: ${chalk.cyan(`netlify agents:sync ${runner.id}`)}`) + } + log() log(chalk.bold('Actions:')) if (runner.state === 'running' || runner.state === 'new') { diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index b5d2b031ed5..1a769b03b81 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -197,12 +197,14 @@ export const createAgentsCommand = (program: BaseCommand) => { .argument('<id>', 'agent task ID') .description('Publish an agent task’s changes to production') .option('-y, --yes', 'skip confirmation prompt') + .option('--force', 'publish even when the run is out of sync with production') .option('--json', 'output result as JSON') .option('--project <project>', 'project ID or name (if not in a linked directory)') .hook('preAction', requiresSiteInfoWithProject) .addExamples([ 'netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a', 'netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --yes', + 'netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --force', ]) .action(async (id: string, options: OptionValues, command: BaseCommand) => { const { agentsPublish } = await import('./agents-publish.js') diff --git a/src/commands/agents/utils.ts b/src/commands/agents/utils.ts index c22dd3f2eb0..3849dcc46a8 100644 --- a/src/commands/agents/utils.ts +++ b/src/commands/agents/utils.ts @@ -49,6 +49,23 @@ export const validatePrompt = (input: string): true | string => { return true } +export const TITLE_MAX_LENGTH = 200 + +const UNICODE_TAG_PATTERN = /[\u{E0000}-\u{E007F}]/gu +const CONTROL_CHAR_PATTERN = /\p{Cc}/gu + +export const sanitizePromptText = (text: string): string => text.replace(UNICODE_TAG_PATTERN, '') + +export const sanitizeRunnerTitle = (title: string): string => + sanitizePromptText(title).replace(CONTROL_CHAR_PATTERN, '').trim() + +export const validateRunnerTitle = (title: string): true | string => { + const sanitized = sanitizeRunnerTitle(title) + if (!sanitized) return 'A non-empty title is required' + if (sanitized.length > TITLE_MAX_LENGTH) return `Title must be ${TITLE_MAX_LENGTH.toString()} characters or fewer` + return true +} + export const validateAgent = (agent: string): true | string => { const validAgents = AVAILABLE_AGENTS.map((entry) => entry.value) as string[] if (!validAgents.includes(agent)) { diff --git a/tests/integration/commands/agents/agents-commit.test.ts b/tests/integration/commands/agents/agents-commit.test.ts index 6d112d3a033..0119bea9858 100644 --- a/tests/integration/commands/agents/agents-commit.test.ts +++ b/tests/integration/commands/agents/agents-commit.test.ts @@ -125,14 +125,61 @@ describe('agents:commit command', () => { }) }) - test('should require --branch when stdin is not a TTY', async (t) => { + test('should default to runner.branch when --branch is omitted', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: { ...mockAgentRunner, branch: 'feature-x' }, + }, + { + path: 'agent_runners/test_id/commit', + method: 'POST' as const, + response: { ...mockAgentRunner, merge_commit_sha: 'abc' }, + }, + ] + await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(baseRoutes, async ({ apiUrl }) => { - await expect(callCli(['agents:commit', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( - '--branch is required when stdin is not a TTY', - ) + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli(['agents:commit', 'test_id', '--json'], getCLIOptions({ apiUrl, builder })) + const commitRequest = requests.find((r) => r.path.endsWith('/commit') && r.method === 'POST') + expect(commitRequest?.body).toEqual({ target_branch: 'feature-x' }) + }) + }) + }) + + test('should default to runner.pr_branch when an open PR exists', async (t) => { + const runnerWithPr = { + ...mockAgentRunner, + branch: 'feature-x', + pr_branch: 'pr-branch', + pr_url: 'https://github.com/x/y/pull/1', + pr_state: 'open', + } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerWithPr, + }, + { + path: 'agent_runners/test_id/commit', + method: 'POST' as const, + response: { ...runnerWithPr, merge_commit_sha: 'abc' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli(['agents:commit', 'test_id', '--json'], getCLIOptions({ apiUrl, builder })) + const commitRequest = requests.find((r) => r.path.endsWith('/commit') && r.method === 'POST') + expect(commitRequest?.body).toEqual({ target_branch: 'pr-branch' }) }) }) }) diff --git a/tests/integration/commands/agents/agents-publish.test.ts b/tests/integration/commands/agents/agents-publish.test.ts index 3f69ee73f19..ea74d086e95 100644 --- a/tests/integration/commands/agents/agents-publish.test.ts +++ b/tests/integration/commands/agents/agents-publish.test.ts @@ -22,10 +22,22 @@ describe('agents:publish command', () => { { path: 'accounts', response: [{ slug: 'test-account' }] }, ] + const runnerInSync = { + ...mockAgentRunner, + rebase_available: false, + merge_target_available: false, + needs_git_sync: false, + } + test('should publish to production with --yes', async (t) => { - const runnerWithCommit = { ...mockAgentRunner, merge_commit_sha: 'def5678' } + const runnerWithCommit = { ...runnerInSync, merge_commit_sha: 'def5678' } const routes = [ ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerInSync, + }, { path: 'agent_runners/test_id/publish_to_production', method: 'POST' as const, @@ -50,10 +62,19 @@ describe('agents:publish command', () => { }) test('should refuse to publish without --yes when stdin is not a TTY', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerInSync, + }, + ] + await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(baseRoutes, async ({ apiUrl }) => { + await withMockApi(routes, async ({ apiUrl }) => { await expect(callCli(['agents:publish', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( 'Refusing to publish without --yes when stdin is not a TTY', ) @@ -64,10 +85,15 @@ describe('agents:publish command', () => { test('should publish without --yes if --json is set (treats it as non-interactive)', async (t) => { const routes = [ ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerInSync, + }, { path: 'agent_runners/test_id/publish_to_production', method: 'POST' as const, - response: mockAgentRunner, + response: runnerInSync, }, ] @@ -79,9 +105,61 @@ describe('agents:publish command', () => { ['agents:publish', 'test_id', '--json'], getCLIOptions({ apiUrl, builder }), true, - )) as typeof mockAgentRunner + )) as typeof runnerInSync + + expect(cliResponse).toEqual(runnerInSync) + }) + }) + }) + + test('should refuse to publish an out-of-date run without --force', async (t) => { + const staleRunner = { ...mockAgentRunner, rebase_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: staleRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:publish', 'test_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Refusing to publish out-of-date run without --force') + }) + }) + }) - expect(cliResponse).toEqual(mockAgentRunner) + test('should publish an out-of-date run with --force', async (t) => { + const staleRunner = { ...mockAgentRunner, rebase_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: staleRunner, + }, + { + path: 'agent_runners/test_id/publish_to_production', + method: 'POST' as const, + response: { ...staleRunner, merge_commit_sha: 'abc' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:publish', 'test_id', '--force', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Published agent task to production!') }) }) }) @@ -89,6 +167,11 @@ describe('agents:publish command', () => { test('should handle API errors', async (t) => { const routes = [ ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerInSync, + }, { path: 'agent_runners/test_id/publish_to_production', method: 'POST' as const, diff --git a/tests/integration/commands/agents/agents-rename.test.ts b/tests/integration/commands/agents/agents-rename.test.ts index 40eac331b0a..f529c335648 100644 --- a/tests/integration/commands/agents/agents-rename.test.ts +++ b/tests/integration/commands/agents/agents-rename.test.ts @@ -93,6 +93,49 @@ describe('agents:rename command', () => { }) }) + test('should reject titles longer than 200 chars', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + const longTitle = 'a'.repeat(201) + await expect( + callCli(['agents:rename', 'test_id', longTitle], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Title must be 200 characters or fewer') + }) + }) + }) + + test('should strip hidden Unicode tag characters before sending', async (t) => { + const renamed = { ...mockAgentRunner, title: 'Clean title' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'PATCH' as const, + response: renamed, + validateRequest: (request: { body: string }) => { + const body = JSON.parse(request.body) as { title: string } + expect(body.title).toBe('Clean title') + }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const tagChar = String.fromCodePoint(0xe0041) + const cliResponse = (await callCli( + ['agents:rename', 'test_id', `Clean${tagChar} title`], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Agent task renamed') + }) + }) + }) + test('should surface 404 when the task is missing', async (t) => { const routes = [ ...baseRoutes, diff --git a/tests/integration/commands/agents/agents-show.test.ts b/tests/integration/commands/agents/agents-show.test.ts index 0cabc174a1a..7a9925274f3 100644 --- a/tests/integration/commands/agents/agents-show.test.ts +++ b/tests/integration/commands/agents/agents-show.test.ts @@ -56,7 +56,7 @@ describe('agents:show command', () => { expect(cliResponse).toContain('Site: site-name') expect(cliResponse).toContain('Agent: Claude') expect(cliResponse).toContain('Branch: main') - expect(cliResponse).toContain('Prompt: Create a login form') + expect(cliResponse).toContain('Title: Create a login form') }) }) }) From 525a03dd3cdfce4a6070e3090d3ce65ab697296d Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya <vaibhavacharya111@gmail.com> Date: Mon, 11 May 2026 16:17:06 +0530 Subject: [PATCH 12/18] fix(agents): consistent 404 messages; carry over follow-up agent/model; cleanup orphaned uploads; clamp --per-page; warn on dropped account filters; watch-loop 404 handling --- src/commands/agents/agents-archive.ts | 3 +- src/commands/agents/agents-follow-up.ts | 40 ++++++-- src/commands/agents/agents-list.ts | 15 ++- src/commands/agents/agents-open.ts | 3 +- src/commands/agents/agents-redeploy.ts | 3 +- src/commands/agents/agents-revert.ts | 3 +- src/commands/agents/agents-show.ts | 31 +++++-- src/commands/agents/agents-stop.ts | 6 +- src/commands/agents/attachments.ts | 91 ++++++++++++------- src/commands/agents/constants.ts | 1 + src/commands/agents/types.ts | 1 + .../commands/agents/agents-archive.test.ts | 26 +++++- .../commands/agents/agents-follow-up.test.ts | 58 ++++++++++++ .../commands/agents/agents-stop.test.ts | 2 +- 14 files changed, 228 insertions(+), 55 deletions(-) diff --git a/src/commands/agents/agents-archive.ts b/src/commands/agents/agents-archive.ts index c66d7bd518d..898b15f21bf 100644 --- a/src/commands/agents/agents-archive.ts +++ b/src/commands/agents/agents-archive.ts @@ -47,7 +47,8 @@ export const agentsArchive = async (id: string, options: AgentArchiveOptions, co return result } catch (error_) { stopSpinner({ spinner, error: true }) - const error = error_ as Error + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) return logAndThrowError(`Failed to archive: ${error.message}`) } } diff --git a/src/commands/agents/agents-follow-up.ts b/src/commands/agents/agents-follow-up.ts index 85fe3c89035..43b5464c734 100644 --- a/src/commands/agents/agents-follow-up.ts +++ b/src/commands/agents/agents-follow-up.ts @@ -6,7 +6,7 @@ import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' import { createAgentsApi } from './api.js' import { uploadAttachments, type UploadedAttachment } from './attachments.js' -import { type AvailableAgent } from './constants.js' +import { AVAILABLE_AGENTS, TERMINAL_SESSION_STATES, type AvailableAgent } from './constants.js' import type { CreateAgentRunnerSessionPayload } from './types.js' import { checkModelAvailability, @@ -56,14 +56,39 @@ export const agentsFollowUp = async ( const promptValid = validatePrompt(finalPrompt) if (promptValid !== true) return logAndThrowError(promptValid) - let agent: AvailableAgent | undefined + let lastSessionAgent: AvailableAgent | undefined + let lastSessionModel: string | undefined + let recent: import('./types.js').AgentRunnerSession | undefined + try { + const recentSessions = await api.listAgentRunnerSessions(id, { page: 1, per_page: 1, order_by: 'desc' }) + if (recentSessions.length > 0) recent = recentSessions[0] + } catch { + // If lookup fails, fall through and let the create call surface the real error. + } + + if (recent && !TERMINAL_SESSION_STATES.includes(recent.state as (typeof TERMINAL_SESSION_STATES)[number])) { + log(chalk.yellow('A session is already running on this task. Wait for it to finish or stop it first:')) + log(` ${chalk.cyan(`netlify agents:stop ${id}`)}`) + return logAndThrowError('Cannot create a follow-up while a session is still active') + } + + if (recent) { + const previousAgent = recent.agent_config?.agent + if (previousAgent && AVAILABLE_AGENTS.some((entry) => entry.value === previousAgent)) { + lastSessionAgent = previousAgent + } + if (recent.agent_config?.model) lastSessionModel = recent.agent_config.model + } + + let agent: AvailableAgent | undefined = lastSessionAgent if (options.agent) { const valid = validateAgent(options.agent) if (valid !== true) return logAndThrowError(valid) agent = options.agent as AvailableAgent } - if (options.model && agent) { - const valid = await checkModelAvailability(api, agent, options.model) + const model = options.model ?? lastSessionModel + if (model && agent) { + const valid = await checkModelAvailability(api, agent, model) if (valid !== true) log(chalk.yellow(`⚠ ${valid}`)) } @@ -86,7 +111,7 @@ export const agentsFollowUp = async ( const payload: CreateAgentRunnerSessionPayload = { prompt: sanitizePromptText(finalPrompt), agent, - model: options.model, + model, file_keys: attachments.length > 0 ? attachments.map((entry) => entry.fileKey) : undefined, } @@ -106,7 +131,7 @@ export const agentsFollowUp = async ( log(` Task ID: ${chalk.cyan(id)}`) log(` Session ID: ${chalk.cyan(session.id)}`) log(` Prompt: ${chalk.dim(finalPrompt)}`) - if (agent) log(` Agent: ${chalk.cyan(getAgentName(agent))}${options.model ? ` (${options.model})` : ''}`) + if (agent) log(` Agent: ${chalk.cyan(getAgentName(agent))}${model ? ` (${model})` : ''}`) log(` Status: ${formatStatus(session.state)}`) log() log(chalk.bold('Monitor progress:')) @@ -115,7 +140,8 @@ export const agentsFollowUp = async ( return session } catch (error_) { stopSpinner({ spinner, error: true }) - const error = error_ as Error + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) if (error.message.toLowerCase().includes('active session')) { log() log(chalk.yellow('A session is already running on this task. Wait for it to finish or stop it first:')) diff --git a/src/commands/agents/agents-list.ts b/src/commands/agents/agents-list.ts index 4b71c3e1eed..1f30b4706b7 100644 --- a/src/commands/agents/agents-list.ts +++ b/src/commands/agents/agents-list.ts @@ -39,6 +39,8 @@ const parsePositiveInt = (input: string | undefined, name: string): number | und return Number.parseInt(input, 10) } +const MAX_PER_PAGE = 100 + const buildFilters = (options: AgentListOptions): ListAgentRunnersFilters => { const filters: ListAgentRunnersFilters = {} if (options.status) { @@ -52,7 +54,11 @@ const buildFilters = (options: AgentListOptions): ListAgentRunnersFilters => { filters.from = toUnixSeconds(options.since) filters.to = toUnixSeconds(options.until) filters.page = parsePositiveInt(options.page, 'page') - filters.per_page = parsePositiveInt(options.perPage, 'per-page') + const perPage = parsePositiveInt(options.perPage, 'per-page') + if (perPage !== undefined && perPage > MAX_PER_PAGE) { + throw new Error(`--per-page must be ${MAX_PER_PAGE.toString()} or fewer (the server caps at ${MAX_PER_PAGE.toString()})`) + } + filters.per_page = perPage return filters } @@ -69,6 +75,13 @@ export const agentsList = async (options: AgentListOptions, command: BaseCommand return logAndThrowError((error_ as Error).message) } + if (options.account) { + const droppedFilters = ['branch', 'since', 'until'].filter((key) => options[key as keyof AgentListOptions]) + if (droppedFilters.length > 0) { + log(chalk.yellow(`⚠ --${droppedFilters.join(', --')} are ignored when --account is set.`)) + } + } + const spinner = startSpinner({ text: 'Fetching agent tasks...' }) try { diff --git a/src/commands/agents/agents-open.ts b/src/commands/agents/agents-open.ts index d31a5ab0d83..60a68da82fd 100644 --- a/src/commands/agents/agents-open.ts +++ b/src/commands/agents/agents-open.ts @@ -45,7 +45,8 @@ export const agentsOpen = async ( stopSpinner({ spinner }) } catch (error_) { stopSpinner({ spinner, error: true }) - const error = error_ as Error + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) return logAndThrowError(`Failed to fetch agent task: ${error.message}`) } diff --git a/src/commands/agents/agents-redeploy.ts b/src/commands/agents/agents-redeploy.ts index 0a73ca1fa66..a2671fbebef 100644 --- a/src/commands/agents/agents-redeploy.ts +++ b/src/commands/agents/agents-redeploy.ts @@ -63,7 +63,8 @@ export const agentsRedeploy = async (id: string, options: AgentRedeployOptions, return session } catch (error_) { stopSpinner({ spinner, error: true }) - const error = error_ as Error + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task or session not found: ${id} / ${sessionId}`) return logAndThrowError(`Failed to redeploy: ${error.message}`) } } diff --git a/src/commands/agents/agents-revert.ts b/src/commands/agents/agents-revert.ts index b8e6487d6b3..19f64b5dddb 100644 --- a/src/commands/agents/agents-revert.ts +++ b/src/commands/agents/agents-revert.ts @@ -49,7 +49,8 @@ export const agentsRevert = async (id: string, options: AgentRevertOptions, comm return runner } catch (error_) { stopSpinner({ spinner, error: true }) - const error = error_ as Error + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task or session not found: ${id} / ${options.session}`) return logAndThrowError(`Failed to revert: ${error.message}`) } } diff --git a/src/commands/agents/agents-show.ts b/src/commands/agents/agents-show.ts index a1d55693c61..894f7b04d22 100644 --- a/src/commands/agents/agents-show.ts +++ b/src/commands/agents/agents-show.ts @@ -246,8 +246,14 @@ const renderSessionInline = (session: AgentRunnerSession, index: number, total: } log(` ${meta.join(' • ')}`) log(` ${chalk.dim('id:')} ${session.id}`) + if (session.state === 'running' && session.current_task) { + log(` ${chalk.dim('current:')} ${chalk.yellow(session.current_task)}`) + } if (session.deploy_url) log(` ${chalk.dim('preview:')} ${chalk.blue(session.deploy_url)}`) if (session.commit_sha) log(` ${chalk.dim('commit:')} ${chalk.cyan(session.commit_sha)}`) + if (session.attached_file_keys && session.attached_file_keys.length > 0) { + log(` ${chalk.dim('attachments:')} ${session.attached_file_keys.length.toString()} file(s)`) + } if (session.steps && session.steps.length > 0) { log(` ${chalk.dim('Steps:')}`) @@ -357,10 +363,18 @@ const takeSnapshot = (runner: AgentRunner, sessions: AgentRunnerSession[]): Watc const watchAgentTask = async (api: AgentsApi, id: string, command: BaseCommand) => { const renderer = new WatchRenderer() let previous: WatchSnapshot | null = null - let [lastRunner, lastSessions] = await Promise.all([ - api.getAgentRunner(id), - api.listAgentRunnerSessions(id, { page: 1, per_page: 100 }), - ]) + let lastRunner: AgentRunner + let lastSessions: AgentRunnerSession[] + try { + ;[lastRunner, lastSessions] = await Promise.all([ + api.getAgentRunner(id), + api.listAgentRunnerSessions(id, { page: 1, per_page: 100, order_by: 'desc' }), + ]) + } catch (error_) { + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to watch agent task: ${error.message}`) + } log(`${chalk.cyan('Watching')} agent task ${chalk.bold(id)} ${chalk.dim('(Ctrl+C to stop)')}`) log() @@ -384,8 +398,13 @@ const watchAgentTask = async (api: AgentsApi, id: string, command: BaseCommand) api.listAgentRunnerSessions(id, { page: 1, per_page: 100, order_by: 'desc' }), ]) } catch (error_) { - const error = error_ as Error - renderer.print(`${chalk.yellow('!')} ${chalk.dim(`poll failed: ${error.message} — retrying`)}`) + const error = error_ as Error & { status?: number } + if (error.status === 404) { + renderer.stop() + log(chalk.yellow(`! Agent task ${id} is no longer accessible (may have been archived or deleted).`)) + return lastRunner + } + renderer.print(`${chalk.yellow('!')} ${chalk.dim(`poll failed: ${error.message}, retrying`)}`) } } } finally { diff --git a/src/commands/agents/agents-stop.ts b/src/commands/agents/agents-stop.ts index ff7f3e861e6..cb95ab5f030 100644 --- a/src/commands/agents/agents-stop.ts +++ b/src/commands/agents/agents-stop.ts @@ -34,7 +34,8 @@ const stopRunner = async (api: ReturnType<typeof createAgentsApi>, id: string, o stopSpinner({ spinner: fetchSpinner }) } catch (error_) { stopSpinner({ spinner: fetchSpinner, error: true }) - const error = error_ as Error + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) return logAndThrowError(`Failed to fetch agent task: ${error.message}`) } @@ -88,7 +89,8 @@ const stopSession = async ( stopSpinner({ spinner: fetchSpinner }) } catch (error_) { stopSpinner({ spinner: fetchSpinner, error: true }) - const error = error_ as Error + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task or session not found: ${id} / ${sessionId}`) return logAndThrowError(`Failed to fetch session: ${error.message}`) } diff --git a/src/commands/agents/attachments.ts b/src/commands/agents/attachments.ts index ef277fad2b1..c22033bbd70 100644 --- a/src/commands/agents/attachments.ts +++ b/src/commands/agents/attachments.ts @@ -2,7 +2,7 @@ import fs from 'fs/promises' import path from 'path' import type { AgentsApi } from './api.js' -import { MAX_ATTACHMENT_SIZE_BYTES } from './constants.js' +import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS_PER_REQUEST } from './constants.js' import { formatBytes, getMimeType } from './utils.js' export interface UploadedAttachment { @@ -13,12 +13,28 @@ export interface UploadedAttachment { contentType: string } +const cleanupOrphans = async (api: AgentsApi, accountId: string, fileKeys: string[]): Promise<void> => { + await Promise.allSettled( + fileKeys.map(async (fileKey) => { + try { + const { delete_url: deleteUrl } = await api.createDeleteUrl({ account_id: accountId, file_key: fileKey }) + await fetch(deleteUrl, { method: 'DELETE' }) + } catch { + // Best-effort cleanup; if it fails, the orphan is the user's tenant problem. + } + }), + ) +} + export const uploadAttachments = async ( api: AgentsApi, accountId: string, filePaths: string[], ): Promise<UploadedAttachment[]> => { if (filePaths.length === 0) return [] + if (filePaths.length > MAX_ATTACHMENTS_PER_REQUEST) { + throw new Error(`Too many attachments: ${filePaths.length.toString()} given, max is ${MAX_ATTACHMENTS_PER_REQUEST.toString()}`) + } const resolved = await Promise.all( filePaths.map(async (filePath) => { @@ -40,41 +56,52 @@ export const uploadAttachments = async ( ) const uploaded: UploadedAttachment[] = [] - for (const file of resolved) { - const { upload_url: uploadUrl, file_key: fileKey } = await api.createUploadUrl({ - account_id: accountId, - filename: file.filename, - content_type: file.contentType, - }) - - const body = await fs.readFile(file.path) - const controller = new AbortController() - const timeout = setTimeout(() => { - controller.abort() - }, 60_000) - let putResponse: Response - try { - putResponse = await fetch(uploadUrl, { - method: 'PUT', - body: new Uint8Array(body), - headers: { 'Content-Type': file.contentType }, - signal: controller.signal, + try { + for (const file of resolved) { + const { upload_url: uploadUrl, file_key: fileKey } = await api.createUploadUrl({ + account_id: accountId, + filename: file.filename, + content_type: file.contentType, }) - } catch (error_) { - const error = error_ as Error - if (error.name === 'AbortError') { - throw new Error(`Upload of ${file.filename} timed out after 60s`) + + const body = await fs.readFile(file.path) + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, 60_000) + let putResponse: Response + try { + putResponse = await fetch(uploadUrl, { + method: 'PUT', + body: new Uint8Array(body), + headers: { 'Content-Type': file.contentType }, + signal: controller.signal, + }) + } catch (error_) { + const error = error_ as Error + if (error.name === 'AbortError') { + throw new Error(`Upload of ${file.filename} timed out after 60s`) + } + throw error + } finally { + clearTimeout(timeout) + } + if (!putResponse.ok) { + throw new Error( + `Failed to upload ${file.filename}: HTTP ${putResponse.status.toString()} ${putResponse.statusText}`, + ) } - throw error - } finally { - clearTimeout(timeout) + uploaded.push({ ...file, fileKey }) } - if (!putResponse.ok) { - throw new Error( - `Failed to upload ${file.filename}: HTTP ${putResponse.status.toString()} ${putResponse.statusText}`, + return uploaded + } catch (error) { + if (uploaded.length > 0) { + await cleanupOrphans( + api, + accountId, + uploaded.map((entry) => entry.fileKey), ) } - uploaded.push({ ...file, fileKey }) + throw error } - return uploaded } diff --git a/src/commands/agents/constants.ts b/src/commands/agents/constants.ts index 8ab3e921351..0c608afedfc 100644 --- a/src/commands/agents/constants.ts +++ b/src/commands/agents/constants.ts @@ -40,6 +40,7 @@ export const TERMINAL_AGENT_STATES = ['done', 'error', 'cancelled', 'archived'] export const TERMINAL_SESSION_STATES = ['done', 'error', 'cancelled'] as const export const MAX_ATTACHMENT_SIZE_BYTES = 10 * 1024 * 1024 +export const MAX_ATTACHMENTS_PER_REQUEST = 20 export type AgentState = (typeof AGENT_STATES)[number] export type SessionState = (typeof SESSION_STATES)[number] diff --git a/src/commands/agents/types.ts b/src/commands/agents/types.ts index 67608298d1e..93a895c3882 100644 --- a/src/commands/agents/types.ts +++ b/src/commands/agents/types.ts @@ -88,6 +88,7 @@ export interface AgentRunnerSession { updated_at: string done_at?: string title?: string + current_task?: string prompt: string agent_config?: AgentConfig result?: string diff --git a/tests/integration/commands/agents/agents-archive.test.ts b/tests/integration/commands/agents/agents-archive.test.ts index 6a4d8f9452c..f47ca19a79f 100644 --- a/tests/integration/commands/agents/agents-archive.test.ts +++ b/tests/integration/commands/agents/agents-archive.test.ts @@ -84,7 +84,7 @@ describe('agents:archive command', () => { }) }) - test('should handle archive failure', async (t) => { + test('should handle archive failure when the task is missing', async (t) => { const routes = [ ...baseRoutes, { @@ -101,7 +101,29 @@ describe('agents:archive command', () => { await withMockApi(routes, async ({ apiUrl }) => { await expect( callCli(['agents:archive', 'test_id', '--yes'], getCLIOptions({ apiUrl, builder })), - ).rejects.toThrow('Failed to archive: Not found') + ).rejects.toThrow('Agent task not found: test_id') + }) + }) + }) + + test('should surface other archive failures generically', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/archive', + method: 'POST' as const, + status: 500, + response: { error: 'something exploded' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:archive', 'test_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Failed to archive: something exploded') }) }) }) diff --git a/tests/integration/commands/agents/agents-follow-up.test.ts b/tests/integration/commands/agents/agents-follow-up.test.ts index 65768730454..3e5b2f089b0 100644 --- a/tests/integration/commands/agents/agents-follow-up.test.ts +++ b/tests/integration/commands/agents/agents-follow-up.test.ts @@ -171,6 +171,64 @@ describe('agents:follow-up command', () => { }) }) + test('should carry over agent and model from the latest done session', async (t) => { + const lastSession = { + ...mockAgentSession, + id: 'prev_id', + state: 'done', + agent_config: { agent: 'codex', model: 'gpt-4.1' }, + } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'GET' as const, + response: [lastSession], + }, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + response: { ...mockAgentSession, agent_config: { agent: 'codex', model: 'gpt-4.1' } }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli(['agents:follow-up', 'test_id', 'Update README'], getCLIOptions({ apiUrl, builder })) + const sessionRequest = requests.find((r) => r.path.endsWith('/sessions') && r.method === 'POST') + expect(sessionRequest?.body).toMatchObject({ + prompt: 'Update README', + agent: 'codex', + model: 'gpt-4.1', + }) + }) + }) + }) + + test('should refuse to send a follow-up while the latest session is still running', async (t) => { + const activeSession = { ...mockAgentSession, state: 'running' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'GET' as const, + response: [activeSession], + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:follow-up', 'test_id', 'Add tests'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Cannot create a follow-up while a session is still active') + }) + }) + }) + test('should require agent ID argument', async (t) => { await withSiteBuilder(t, async (builder) => { await builder.build() diff --git a/tests/integration/commands/agents/agents-stop.test.ts b/tests/integration/commands/agents/agents-stop.test.ts index f5fd697f61d..c7894b97d0a 100644 --- a/tests/integration/commands/agents/agents-stop.test.ts +++ b/tests/integration/commands/agents/agents-stop.test.ts @@ -147,7 +147,7 @@ describe('agents:stop command', () => { await withMockApi(routes, async ({ apiUrl }) => { await expect(callCli(['agents:stop', 'invalid_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( - 'Failed to fetch agent task: Not found', + 'Agent task not found: invalid_id', ) }) }) From bc58a5cc41b3a1f120600a0a1279de9addf89496 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya <vaibhavacharya111@gmail.com> Date: Mon, 11 May 2026 16:22:47 +0530 Subject: [PATCH 13/18] fix(agents): richer show output (sha, per-session user/published/discarded); pretty PR state; commit/pr require git; sync names the target --- src/commands/agents/agents-commit.ts | 4 ++++ src/commands/agents/agents-pr.ts | 7 ++++++- src/commands/agents/agents-show.ts | 17 ++++++++++++++--- src/commands/agents/agents-sync.ts | 11 ++++++----- src/commands/agents/utils.ts | 9 +++++++++ 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/commands/agents/agents-commit.ts b/src/commands/agents/agents-commit.ts index ec997f77ef5..109e5064f41 100644 --- a/src/commands/agents/agents-commit.ts +++ b/src/commands/agents/agents-commit.ts @@ -27,6 +27,10 @@ const pickDefaultBranch = (runner: AgentRunner): { branch: string; reason: strin export const agentsCommit = async (id: string, options: AgentCommitOptions, command: BaseCommand) => { if (!id) return logAndThrowError('Agent task ID is required') await command.authenticate() + const { siteInfo } = command.netlify + if (!siteInfo.build_settings?.repo_url) { + return logAndThrowError('This project is not connected to a git repository. Commits are only available for git-backed projects.') + } const api = createAgentsApi(command.netlify) let targetBranch = options.branch?.trim() diff --git a/src/commands/agents/agents-pr.ts b/src/commands/agents/agents-pr.ts index ba51e4d35b4..5397866df09 100644 --- a/src/commands/agents/agents-pr.ts +++ b/src/commands/agents/agents-pr.ts @@ -12,6 +12,10 @@ interface AgentPrOptions extends OptionValues { export const agentsPullRequest = async (id: string, options: AgentPrOptions, command: BaseCommand) => { if (!id) return logAndThrowError('Agent task ID is required') await command.authenticate() + const { siteInfo } = command.netlify + if (!siteInfo.build_settings?.repo_url) { + return logAndThrowError('This project is not connected to a git repository. Pull requests are only available for git-backed projects.') + } const api = createAgentsApi(command.netlify) const spinner = startSpinner({ text: 'Creating pull request...' }) @@ -37,7 +41,8 @@ export const agentsPullRequest = async (id: string, options: AgentPrOptions, com return runner } catch (error_) { stopSpinner({ spinner, error: true }) - const error = error_ as Error + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) return logAndThrowError(`Failed to create pull request: ${error.message}`) } } diff --git a/src/commands/agents/agents-show.ts b/src/commands/agents/agents-show.ts index 894f7b04d22..49afcc402ed 100644 --- a/src/commands/agents/agents-show.ts +++ b/src/commands/agents/agents-show.ts @@ -6,7 +6,7 @@ import type BaseCommand from '../base-command.js' import { createAgentsApi, type AgentsApi } from './api.js' import { TERMINAL_AGENT_STATES, TERMINAL_SESSION_STATES } from './constants.js' import type { AgentRunner, AgentRunnerSession } from './types.js' -import { formatDate, formatDuration, formatStatus, formatUsage, getAgentName } from './utils.js' +import { formatDate, formatDuration, formatPrState, formatStatus, formatUsage, getAgentName } from './utils.js' interface AgentShowOptions extends OptionValues { json?: boolean @@ -148,6 +148,7 @@ const renderAgentTask = (runner: AgentRunner, sessions: AgentRunnerSession[], co log(` Task ID: ${chalk.cyan(runner.id)}`) log(` Status: ${formatStatus(runner.state ?? 'unknown')}`) log(` Site: ${chalk.cyan(siteInfo.name)} (${site.id ?? ''})`) + if (runner.sha) log(` Start commit: ${chalk.cyan(runner.sha.slice(0, 7))}`) if (runner.user) log(` Created by: ${runner.user.full_name ?? 'Anonymous'}`) if (runner.contributors && runner.contributors.length > 1) { log(` Contributors: ${runner.contributors.map((entry) => entry.full_name ?? 'Anonymous').join(', ')}`) @@ -191,11 +192,12 @@ const renderAgentTask = (runner: AgentRunner, sessions: AgentRunnerSession[], co } } - if (runner.pr_url || runner.pr_error) { + if (runner.pr_url || runner.pr_error || runner.pr_is_being_created) { log() log(chalk.bold('Pull Request:')) if (runner.pr_url) log(` URL: ${chalk.blue(runner.pr_url)}`) - if (runner.pr_state) log(` State: ${chalk.cyan(runner.pr_state)}`) + if (runner.pr_state) log(` State: ${chalk.cyan(formatPrState(runner.pr_state))}`) + if (runner.pr_is_being_created) log(` ${chalk.yellow('Status:')} being created`) if (runner.pr_error) log(` ${chalk.red('Error:')} ${runner.pr_error}`) } @@ -239,6 +241,8 @@ const renderSessionInline = (session: AgentRunnerSession, index: number, total: log(header) const meta: string[] = [formatStatus(session.state)] if (session.mode && session.mode !== 'normal') meta.push(chalk.dim(`mode: ${session.mode}`)) + if (session.is_published) meta.push(chalk.green('PUBLISHED')) + if (session.is_discarded) meta.push(chalk.gray('DISCARDED')) if (session.done_at) { meta.push(chalk.dim(`took ${formatDuration(session.created_at, session.done_at)}`)) } else if (session.state === 'running') { @@ -246,6 +250,7 @@ const renderSessionInline = (session: AgentRunnerSession, index: number, total: } log(` ${meta.join(' • ')}`) log(` ${chalk.dim('id:')} ${session.id}`) + if (session.user) log(` ${chalk.dim('by:')} ${session.user.full_name ?? 'Anonymous'}`) if (session.state === 'running' && session.current_task) { log(` ${chalk.dim('current:')} ${chalk.yellow(session.current_task)}`) } @@ -281,9 +286,15 @@ const renderSessionDetail = (session: AgentRunnerSession, runnerId: string, comm log(` Session ID: ${chalk.cyan(session.id)}`) log(` Task ID: ${chalk.cyan(runnerId)}`) log(` Status: ${formatStatus(session.state)}`) + if (session.is_published) log(` ${chalk.green('Published to production')}`) + if (session.is_discarded) log(` ${chalk.gray('Discarded (a later revert reset to an earlier session)')}`) if (session.mode) log(` Mode: ${chalk.cyan(session.mode)}`) + if (session.user) log(` Author: ${chalk.cyan(session.user.full_name ?? 'Anonymous')}`) if (session.agent_config?.agent) log(` Agent: ${chalk.cyan(getAgentName(session.agent_config.agent))}`) if (session.agent_config?.model) log(` Model: ${chalk.cyan(session.agent_config.model)}`) + if (session.state === 'running' && session.current_task) { + log(` Current Task: ${chalk.yellow(session.current_task)}`) + } log() log(chalk.bold('Prompt:')) diff --git a/src/commands/agents/agents-sync.ts b/src/commands/agents/agents-sync.ts index 3ffc4109148..62b1445becc 100644 --- a/src/commands/agents/agents-sync.ts +++ b/src/commands/agents/agents-sync.ts @@ -21,12 +21,13 @@ const pickStrategy = (runner: AgentRunner): SyncStrategy | null => { return null } -const describeStrategy = (strategy: SyncStrategy): string => { +const describeStrategy = (strategy: SyncStrategy, runner: AgentRunner): string => { + const target = runner.branch ? ` (target: ${runner.branch})` : '' switch (strategy) { case 'sync_git_origin': - return 'sync with the remote git origin (code origin changed)' + return `sync with the remote git origin${target}` case 'merge_target': - return 'merge the latest target branch into this agent run' + return `merge the latest target branch into this agent run${target}` case 'rebase': return 'reapply changes on top of the latest production deploy' } @@ -74,7 +75,7 @@ export const agentsSync = async (id: string, options: AgentSyncOptions, command: { type: 'confirm', name: 'confirmed', - message: `Sync agent task ${id}? This will ${describeStrategy(strategy)}.`, + message: `Sync agent task ${id}? This will ${describeStrategy(strategy, runner)}.`, default: false, }, ]) @@ -91,7 +92,7 @@ export const agentsSync = async (id: string, options: AgentSyncOptions, command: return updated } - log(`${chalk.green('✓')} Sync started: ${describeStrategy(strategy)}.`) + log(`${chalk.green('✓')} Sync started: ${describeStrategy(strategy, runner)}.`) log(` Task ID: ${chalk.cyan(updated.id)}`) log() log(`Watch progress: ${chalk.cyan(`netlify agents:show ${updated.id} --watch`)}`) diff --git a/src/commands/agents/utils.ts b/src/commands/agents/utils.ts index 3849dcc46a8..9125d6bfabb 100644 --- a/src/commands/agents/utils.ts +++ b/src/commands/agents/utils.ts @@ -39,6 +39,15 @@ export const formatStatus = (status: string): string => { return colorFn(status.toUpperCase()) } +const PR_STATE_LABELS: Record<string, string> = { + open: 'Open', + draft: 'Draft', + closed: 'Closed', + merged: 'Merged', +} + +export const formatPrState = (state: string): string => PR_STATE_LABELS[state.toLowerCase()] ?? state + export const validatePrompt = (input: string): true | string => { if (!input || input.trim().length === 0) { return 'Please provide a prompt for the agent' From c3b436b29d787630dfafe800dd61b7c25fee7054 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya <vaibhavacharya111@gmail.com> Date: Mon, 11 May 2026 16:34:28 +0530 Subject: [PATCH 14/18] fix(agents): respect NETLIFY_WEB_UI host; bounded watch retries; clearer publish-after-sync flow; ISO date in list; --no-strip-binary help text --- docs/commands/agents.md | 4 +++- src/commands/agents/agents-create.ts | 3 ++- src/commands/agents/agents-follow-up.ts | 3 +++ src/commands/agents/agents-list.ts | 2 +- src/commands/agents/agents-open.ts | 3 ++- src/commands/agents/agents-publish.ts | 17 +++++++++++----- src/commands/agents/agents-show.ts | 27 +++++++++++++++++++------ src/commands/agents/agents.ts | 2 +- src/commands/agents/utils.ts | 5 +++++ 9 files changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/commands/agents.md b/docs/commands/agents.md index 66504352efd..9cd3f70774d 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -176,7 +176,7 @@ netlify agents:diff - `cumulative` (*boolean*) - with --session, show the cumulative diff up through that session - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `no-color` (*boolean*) - disable color in the output -- `no-strip-binary` (*boolean*) - include raw binary content in the diff (off by default) +- `no-strip-binary` (*boolean*) - include raw binary content in the diff (binary is stripped by default) - `page` (*string*) - page number (1-based) - `per-page` (*string*) - files per page (max 100) - `debug` (*boolean*) - Print debugging information @@ -348,6 +348,7 @@ netlify agents:publish - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `force` (*boolean*) - publish even when the run is out of sync with production - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `yes` (*boolean*) - skip confirmation prompt @@ -357,6 +358,7 @@ netlify agents:publish ```bash netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --yes +netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --force ``` --- diff --git a/src/commands/agents/agents-create.ts b/src/commands/agents/agents-create.ts index 569ef1850ef..a2d47a1ce7f 100644 --- a/src/commands/agents/agents-create.ts +++ b/src/commands/agents/agents-create.ts @@ -11,6 +11,7 @@ import { AVAILABLE_AGENTS, type AvailableAgent } from './constants.js' import { uploadAttachments, type UploadedAttachment } from './attachments.js' import type { CreateAgentRunnerPayload } from './types.js' import { + buildAgentDashboardUrl, checkModelAvailability, formatBytes, formatStatus, @@ -162,7 +163,7 @@ export const agentsCreate = async (promptArg: string, options: AgentCreateOption log(chalk.bold('Monitor progress:')) log(` Watch: ${chalk.cyan(`netlify agents:show ${agentRunner.id} --watch`)}`) log(` Show: ${chalk.cyan(`netlify agents:show ${agentRunner.id}`)}`) - log(` Browser: ${chalk.blue(`https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${agentRunner.id}`)}`) + log(` Browser: ${chalk.blue(buildAgentDashboardUrl(siteInfo.name, agentRunner.id))}`) log() log(chalk.dim('The agent task runs remotely on Netlify infrastructure and may take a few minutes.')) diff --git a/src/commands/agents/agents-follow-up.ts b/src/commands/agents/agents-follow-up.ts index 43b5464c734..4e2d62506d1 100644 --- a/src/commands/agents/agents-follow-up.ts +++ b/src/commands/agents/agents-follow-up.ts @@ -43,6 +43,9 @@ export const agentsFollowUp = async ( let finalPrompt = promptArg || options.prompt if (!finalPrompt) { + if (options.json) { + return logAndThrowError('A prompt is required. Pass it as the positional argument or via --prompt.') + } const { promptInput } = await inquirer.prompt<{ promptInput: string }>([ { type: 'input', diff --git a/src/commands/agents/agents-list.ts b/src/commands/agents/agents-list.ts index 1f30b4706b7..833d4833e2e 100644 --- a/src/commands/agents/agents-list.ts +++ b/src/commands/agents/agents-list.ts @@ -124,7 +124,7 @@ export const agentsList = async (options: AgentListOptions, command: BaseCommand truncateText(runner.title ?? 'No title', 35), baseValue, runner.done_at ? formatDuration(runner.created_at, runner.done_at) : formatDuration(runner.created_at), - new Date(runner.created_at).toLocaleDateString(), + new Date(runner.created_at).toISOString().slice(0, 10), ) } diff --git a/src/commands/agents/agents-open.ts b/src/commands/agents/agents-open.ts index 60a68da82fd..93c66770a98 100644 --- a/src/commands/agents/agents-open.ts +++ b/src/commands/agents/agents-open.ts @@ -5,6 +5,7 @@ import { startSpinner, stopSpinner } from '../../lib/spinner.js' import openBrowser from '../../utils/open-browser.js' import type BaseCommand from '../base-command.js' import { createAgentsApi } from './api.js' +import { buildAgentDashboardUrl } from './utils.js' const VALID_TARGETS = ['preview', 'dashboard', 'pr'] as const type OpenTarget = (typeof VALID_TARGETS)[number] @@ -32,7 +33,7 @@ export const agentsOpen = async ( await command.authenticate() const { siteInfo } = command.netlify const api = createAgentsApi(command.netlify) - const dashboardUrl = `https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${id}` + const dashboardUrl = buildAgentDashboardUrl(siteInfo.name, id) if (target === 'dashboard') { return openUrl(dashboardUrl) diff --git a/src/commands/agents/agents-publish.ts b/src/commands/agents/agents-publish.ts index fe7732e2b16..6dff5464bb6 100644 --- a/src/commands/agents/agents-publish.ts +++ b/src/commands/agents/agents-publish.ts @@ -6,6 +6,7 @@ import { startSpinner, stopSpinner } from '../../lib/spinner.js' import type BaseCommand from '../base-command.js' import { createAgentsApi } from './api.js' import type { AgentRunner } from './types.js' +import { buildAgentDashboardUrl } from './utils.js' interface AgentPublishOptions extends OptionValues { json?: boolean @@ -58,8 +59,11 @@ export const agentsPublish = async (id: string, options: AgentPublishOptions, co name: 'action', message: 'How would you like to proceed?', choices: [ - { name: 'Sync with production first (recommended)', value: 'sync' }, - { name: 'Publish anyway', value: 'publish' }, + { + name: 'Sync with production now, then re-run publish manually (recommended)', + value: 'sync', + }, + { name: 'Publish anyway (use the current diff as-is)', value: 'publish' }, { name: 'Cancel', value: 'cancel' }, ], default: 'sync', @@ -68,9 +72,12 @@ export const agentsPublish = async (id: string, options: AgentPublishOptions, co if (action === 'cancel') return exit() if (action === 'sync') { const { agentsSync } = await import('./agents-sync.js') - return agentsSync(id, { yes: true }, command) + await agentsSync(id, { yes: true }, command) + log() + log(chalk.dim(`After the sync completes, re-run: ${chalk.cyan(`netlify agents:publish ${id}`)}`)) + return } - // action === 'publish' falls through + // action === 'publish' falls through to the publish call below } else { return logAndThrowError('Refusing to publish out-of-date run without --force') } @@ -109,7 +116,7 @@ export const agentsPublish = async (id: string, options: AgentPublishOptions, co log() log(` Task ID: ${chalk.cyan(updated.id)}`) if (updated.merge_commit_sha) log(` Commit: ${chalk.cyan(updated.merge_commit_sha)}`) - log(` Browser: ${chalk.blue(`https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${updated.id}`)}`) + log(` Browser: ${chalk.blue(buildAgentDashboardUrl(siteInfo.name, updated.id))}`) return updated } catch (error_) { stopSpinner({ spinner, error: true }) diff --git a/src/commands/agents/agents-show.ts b/src/commands/agents/agents-show.ts index 49afcc402ed..c558e7a5180 100644 --- a/src/commands/agents/agents-show.ts +++ b/src/commands/agents/agents-show.ts @@ -6,7 +6,15 @@ import type BaseCommand from '../base-command.js' import { createAgentsApi, type AgentsApi } from './api.js' import { TERMINAL_AGENT_STATES, TERMINAL_SESSION_STATES } from './constants.js' import type { AgentRunner, AgentRunnerSession } from './types.js' -import { formatDate, formatDuration, formatPrState, formatStatus, formatUsage, getAgentName } from './utils.js' +import { + buildAgentDashboardUrl, + formatDate, + formatDuration, + formatPrState, + formatStatus, + formatUsage, + getAgentName, +} from './utils.js' interface AgentShowOptions extends OptionValues { json?: boolean @@ -233,7 +241,7 @@ const renderAgentTask = (runner: AgentRunner, sessions: AgentRunnerSession[], co if (runner.latest_session_deploy_url) { log(` Open preview: ${chalk.cyan(`netlify agents:open ${runner.id}`)}`) } - log(` View in browser: ${chalk.blue(`https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${runner.id}`)}`) + log(` View in browser: ${chalk.blue(buildAgentDashboardUrl(siteInfo.name, runner.id))}`) } const renderSessionInline = (session: AgentRunnerSession, index: number, total: number) => { @@ -344,7 +352,7 @@ const renderSessionDetail = (session: AgentRunnerSession, runnerId: string, comm } log() - log(` View in browser: ${chalk.blue(`https://app.netlify.com/projects/${siteInfo.name}/agent-runs/${runnerId}`)}`) + log(` View in browser: ${chalk.blue(buildAgentDashboardUrl(siteInfo.name, runnerId))}`) } interface WatchSnapshot { @@ -390,6 +398,8 @@ const watchAgentTask = async (api: AgentsApi, id: string, command: BaseCommand) log(`${chalk.cyan('Watching')} agent task ${chalk.bold(id)} ${chalk.dim('(Ctrl+C to stop)')}`) log() + const MAX_CONSECUTIVE_FAILURES = 10 + let consecutiveFailures = 0 renderer.start() try { for (;;) { @@ -408,14 +418,19 @@ const watchAgentTask = async (api: AgentsApi, id: string, command: BaseCommand) api.getAgentRunner(id), api.listAgentRunnerSessions(id, { page: 1, per_page: 100, order_by: 'desc' }), ]) + consecutiveFailures = 0 } catch (error_) { const error = error_ as Error & { status?: number } if (error.status === 404) { renderer.stop() - log(chalk.yellow(`! Agent task ${id} is no longer accessible (may have been archived or deleted).`)) - return lastRunner + return logAndThrowError(`Agent task ${id} is no longer accessible (archived or deleted).`) } - renderer.print(`${chalk.yellow('!')} ${chalk.dim(`poll failed: ${error.message}, retrying`)}`) + consecutiveFailures += 1 + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + renderer.stop() + return logAndThrowError(`Watch aborted after ${MAX_CONSECUTIVE_FAILURES.toString()} consecutive polling failures: ${error.message}`) + } + renderer.print(`${chalk.yellow('!')} ${chalk.dim(`poll failed (${consecutiveFailures.toString()}/${MAX_CONSECUTIVE_FAILURES.toString()}): ${error.message}, retrying`)}`) } } } finally { diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index 1a769b03b81..abce1e269ec 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -150,7 +150,7 @@ export const createAgentsCommand = (program: BaseCommand) => { .option('--per-page <n>', 'files per page (max 100)') .option('--session <sid>', 'show a single session diff instead of the task aggregate') .option('--cumulative', 'with --session, show the cumulative diff up through that session') - .option('--no-strip-binary', 'include raw binary content in the diff (off by default)') + .option('--no-strip-binary', 'include raw binary content in the diff (binary is stripped by default)') .option('--no-color', 'disable color in the output') .option('--project <project>', 'project ID or name (if not in a linked directory)') .hook('preAction', requiresSiteInfoWithProject) diff --git a/src/commands/agents/utils.ts b/src/commands/agents/utils.ts index 9125d6bfabb..5762c40ef6b 100644 --- a/src/commands/agents/utils.ts +++ b/src/commands/agents/utils.ts @@ -116,6 +116,11 @@ export const getAgentName = (agent: string): string => { return entry ? entry.name : agent } +const NETLIFY_WEB_UI = (process.env.NETLIFY_WEB_UI ?? 'https://app.netlify.com').replace(/\/+$/, '') + +export const buildAgentDashboardUrl = (siteName: string, agentId: string): string => + `${NETLIFY_WEB_UI}/projects/${siteName}/agent-runs/${agentId}` + export const formatBytes = (bytes: number): string => { if (bytes < 1024) return `${bytes.toString()} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` From e540f67220fd222292611b783edbe74eba60d7af Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya <vaibhavacharya111@gmail.com> Date: Mon, 11 May 2026 16:53:14 +0530 Subject: [PATCH 15/18] fix(agents): address CodeRabbit review and CI failures - prettier-format agents/*; commit regenerated docs so verify-docs passes - agents-create: guard spawnSync git rev-list against non-zero status - agents-list: validate --since/--until as ISO 8601 only - agents-list: account-scope-aware empty state and base column - tests: stop relying on unsupported validateRequest field, assert via recorded requests --- docs/commands/api.md | 4 +- docs/commands/blobs.md | 18 ++++----- docs/commands/build.md | 4 +- docs/commands/claim.md | 4 +- docs/commands/clone.md | 4 +- docs/commands/completion.md | 2 +- docs/commands/database.md | 36 +++++++++--------- docs/commands/dev.md | 4 +- docs/commands/env.md | 18 ++++----- docs/commands/functions.md | 14 +++---- docs/commands/init.md | 4 +- docs/commands/link.md | 4 +- docs/commands/login.md | 4 +- docs/commands/open.md | 8 ++-- docs/commands/recipes.md | 4 +- docs/commands/sites.md | 14 +++---- docs/commands/status.md | 6 +-- docs/commands/switch.md | 2 +- docs/commands/teams.md | 4 +- docs/commands/unlink.md | 2 +- docs/commands/watch.md | 2 +- src/commands/agents/agents-commit.ts | 4 +- src/commands/agents/agents-create.ts | 4 +- src/commands/agents/agents-list.ts | 23 +++++++++-- src/commands/agents/agents-pr.ts | 4 +- src/commands/agents/agents-show.ts | 12 +++++- src/commands/agents/attachments.ts | 4 +- .../commands/agents/agents-create.test.ts | 38 +++++++------------ .../commands/agents/agents-rename.test.ts | 24 +++++------- 29 files changed, 144 insertions(+), 131 deletions(-) diff --git a/docs/commands/api.md b/docs/commands/api.md index a5561037f92..f5b74c676f8 100644 --- a/docs/commands/api.md +++ b/docs/commands/api.md @@ -24,10 +24,10 @@ netlify api **Flags** -- `data` (*string*) - Data to use -- `list` (*boolean*) - List out available API methods - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `data` (*string*) - Data to use +- `list` (*boolean*) - List out available API methods **Examples** diff --git a/docs/commands/blobs.md b/docs/commands/blobs.md index a0a6fb52b4f..a9c6b3d82cb 100644 --- a/docs/commands/blobs.md +++ b/docs/commands/blobs.md @@ -18,9 +18,9 @@ netlify blobs **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -59,10 +59,10 @@ netlify blobs:delete **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `force` (*boolean*) - Bypasses prompts & Force the command to run. - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `force` (*boolean*) - Bypasses prompts & Force the command to run. --- ## `blobs:get` @@ -82,10 +82,10 @@ netlify blobs:get **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `output` (*string*) - Defines the filesystem path where the blob data should be persisted - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `output` (*string*) - Defines the filesystem path where the blob data should be persisted --- ## `blobs:list` @@ -104,12 +104,12 @@ netlify blobs:list **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `directories` (*boolean*) - Indicates that keys with the '/' character should be treated as directories, returning a list of sub-directories at a given level rather than all the keys inside them - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output list contents as JSON - `prefix` (*string*) - A string for filtering down the entries; when specified, only the entries whose key starts with that prefix are returned -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- ## `blobs:set` @@ -130,11 +130,11 @@ netlify blobs:set **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Bypasses prompts & Force the command to run. - `input` (*string*) - Defines the filesystem path where the blob data should be read from -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- diff --git a/docs/commands/build.md b/docs/commands/build.md index 2b345292b36..1da787629f2 100644 --- a/docs/commands/build.md +++ b/docs/commands/build.md @@ -17,11 +17,11 @@ netlify build **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `context` (*string*) - Specify a deploy context for environment variables read during the build (”production”, ”deploy-preview”, ”branch-deploy”, ”dev”) or `branch:your-branch` where `your-branch` is the name of a branch (default: value of CONTEXT or ”production”) - `dry` (*boolean*) - Dry run: show instructions without running them - `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `offline` (*boolean*) - Disables any features that require network access **Examples** diff --git a/docs/commands/claim.md b/docs/commands/claim.md index 74a43631e45..bbfda497427 100644 --- a/docs/commands/claim.md +++ b/docs/commands/claim.md @@ -17,11 +17,11 @@ netlify claim **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `site` (*string*) - The site ID of the anonymous deploy to claim (required) - `token` (*string*) - The drop token provided when the site was deployed (required) -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/clone.md b/docs/commands/clone.md index d67d38b62b5..c82806c8769 100644 --- a/docs/commands/clone.md +++ b/docs/commands/clone.md @@ -28,11 +28,11 @@ netlify clone **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `id` (*string*) - ID of existing Netlify project to link to - `name` (*string*) - Name of existing Netlify project to link to -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/completion.md b/docs/commands/completion.md index 71e9ee5d967..c460b785bfb 100644 --- a/docs/commands/completion.md +++ b/docs/commands/completion.md @@ -46,9 +46,9 @@ netlify completion:install **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in --- diff --git a/docs/commands/database.md b/docs/commands/database.md index b3de82e4d64..0c15d9f8001 100644 --- a/docs/commands/database.md +++ b/docs/commands/database.md @@ -19,9 +19,9 @@ netlify database **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -55,11 +55,11 @@ netlify database status **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `branch` (*string*) - Netlify branch name to query; defaults to the local development database - `json` (*boolean*) - Output result as JSON - `show-credentials` (*boolean*) - Include the full connection string (including username and password) in the output -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -83,10 +83,10 @@ netlify database init **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `yes` (*boolean*) - Non-interactive mode. Accepts the defaults for every prompt. - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `yes` (*boolean*) - Non-interactive mode. Accepts the defaults for every prompt. **Examples** @@ -108,11 +108,11 @@ netlify database connect **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output query results as JSON. When used without --query, prints the connection details as JSON instead. - `query` (*string*) - Execute a single query and exit -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -136,10 +136,10 @@ netlify database reset **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `json` (*boolean*) - Output result as JSON - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - Output result as JSON --- ## `database migrations` @@ -154,9 +154,9 @@ netlify database migrations **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -179,11 +179,11 @@ netlify database migrations apply **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output result as JSON - `to` (*string*) - Target migration name or prefix to apply up to (applies all if omitted) -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- ## `database migrations new` @@ -198,12 +198,12 @@ netlify database migrations new **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `description` (*string*) - Purpose of the migration (used to generate the file name) - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output result as JSON - `scheme` (*timestamp | sequential*) - Numbering scheme for migration prefixes -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -225,11 +225,11 @@ netlify database migrations pull **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `branch` (*string*) - Pull migrations for a specific branch (defaults to 'production'; pass --branch with no value to use local git branch) - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Skip confirmation prompt -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `json` (*boolean*) - Output result as JSON **Examples** @@ -254,11 +254,11 @@ netlify database migrations reset **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `branch` (*string*) - Target a remote preview branch instead of the local development database - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output result as JSON -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/dev.md b/docs/commands/dev.md index cba068156ce..a873f732a89 100644 --- a/docs/commands/dev.md +++ b/docs/commands/dev.md @@ -78,10 +78,10 @@ netlify dev:exec **Flags** -- `context` (*string*) - Specify a deploy context for environment variables (”production”, ”deploy-preview”, ”branch-deploy”, ”dev”) or `branch:your-branch` where `your-branch` is the name of a branch (default: dev) -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `context` (*string*) - Specify a deploy context for environment variables (”production”, ”deploy-preview”, ”branch-deploy”, ”dev”) or `branch:your-branch` where `your-branch` is the name of a branch (default: dev) +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in **Examples** diff --git a/docs/commands/env.md b/docs/commands/env.md index 930790b7fbb..489a1de7a8d 100644 --- a/docs/commands/env.md +++ b/docs/commands/env.md @@ -18,9 +18,9 @@ netlify env **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -56,12 +56,12 @@ netlify env:clone **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Bypasses prompts & Force the command to run. - `from` (*string*) - Project ID (From) - `to` (*string*) - Project ID (To) -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -87,12 +87,12 @@ netlify env:get **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `context` (*string*) - Specify a deploy context for environment variables (”production”, ”deploy-preview”, ”branch-deploy”, ”dev”) or `branch:your-branch` where `your-branch` is the name of a branch - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output environment variables as JSON - `scope` (*builds | functions | post-processing | runtime | any*) - Specify a scope -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `site` (*string*) - A project name or ID to target **Examples** @@ -121,11 +121,11 @@ netlify env:import **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output environment variables as JSON - `replace-existing` (*boolean*) - Replace all existing variables instead of merging them with the current ones -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `site` (*string*) - A project name or ID to target --- @@ -218,12 +218,12 @@ netlify env:unset **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `context` (*string*) - Specify a deploy context for environment variables (”production”, ”deploy-preview”, ”branch-deploy”, ”dev”) or `branch:your-branch` where `your-branch` is the name of a branch (default: all contexts) - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Bypasses prompts & Force the command to run. - `json` (*boolean*) - Output environment variables as JSON -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `site` (*string*) - A project name or ID to target **Examples** diff --git a/docs/commands/functions.md b/docs/commands/functions.md index 9a560a54e0f..f725a9ed79d 100644 --- a/docs/commands/functions.md +++ b/docs/commands/functions.md @@ -19,9 +19,9 @@ netlify functions **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -52,11 +52,11 @@ netlify functions:build **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `functions` (*string*) - Specify a functions directory to build to - `src` (*string*) - Specify the source directory for the functions -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- ## `functions:create` @@ -151,11 +151,11 @@ netlify functions:list **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `functions` (*string*) - Specify a functions directory to list - `json` (*boolean*) - Output function data as JSON -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- ## `functions:serve` @@ -170,12 +170,12 @@ netlify functions:serve **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `functions` (*string*) - Specify a functions directory to serve - `offline` (*boolean*) - Disables any features that require network access - `port` (*string*) - Specify a port for the functions server -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- diff --git a/docs/commands/init.md b/docs/commands/init.md index 9aa21e7e4fe..7521d8b24c0 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -18,12 +18,12 @@ netlify init **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Reinitialize CI hooks if the linked project is already configured to use CI - `git-remote-name` (*string*) - Name of Git remote to use. e.g. "origin" - `manual` (*boolean*) - Manually configure a git remote for CI -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in <!-- AUTO-GENERATED-CONTENT:END --> diff --git a/docs/commands/link.md b/docs/commands/link.md index 5d1b233e7b6..f7ad7656510 100644 --- a/docs/commands/link.md +++ b/docs/commands/link.md @@ -18,13 +18,13 @@ netlify link **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `git-remote-name` (*string*) - Name of Git remote to use. e.g. "origin" - `git-remote-url` (*string*) - URL of the repository (or Github `owner/repo`) to link to - `id` (*string*) - ID of project to link to - `name` (*string*) - Name of project to link to -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/login.md b/docs/commands/login.md index 6304e5dceb4..4ad07638734 100644 --- a/docs/commands/login.md +++ b/docs/commands/login.md @@ -19,12 +19,12 @@ netlify login **Flags** +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `check` (*string*) - Check the status of a login ticket created with --request - `json` (*boolean*) - Output as JSON (for use with --request or --check) - `new` (*boolean*) - Login to new Netlify account - `request` (*string*) - Create a login ticket for agent/human-in-the-loop auth -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in <!-- AUTO-GENERATED-CONTENT:END --> diff --git a/docs/commands/open.md b/docs/commands/open.md index fc15acdd7a9..1accfa6af95 100644 --- a/docs/commands/open.md +++ b/docs/commands/open.md @@ -18,10 +18,10 @@ netlify open **Flags** - `admin` (*boolean*) - Open Netlify project -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `site` (*boolean*) - Open project - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `site` (*boolean*) - Open project | Subcommand | description | |:--------------------------- |:-----| @@ -51,9 +51,9 @@ netlify open:admin **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in **Examples** @@ -74,9 +74,9 @@ netlify open:site **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in **Examples** diff --git a/docs/commands/recipes.md b/docs/commands/recipes.md index dcd1ed9ced1..29f5ddbd8a7 100644 --- a/docs/commands/recipes.md +++ b/docs/commands/recipes.md @@ -21,9 +21,9 @@ netlify recipes **Flags** -- `name` (*string*) - recipe name to use - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `name` (*string*) - recipe name to use | Subcommand | description | |:--------------------------- |:-----| @@ -50,9 +50,9 @@ netlify recipes:list **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in **Examples** diff --git a/docs/commands/sites.md b/docs/commands/sites.md index 37dfa2281df..7ad1e0ef833 100644 --- a/docs/commands/sites.md +++ b/docs/commands/sites.md @@ -19,9 +19,9 @@ netlify sites **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -81,10 +81,10 @@ netlify sites:delete **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `force` (*boolean*) - Delete without prompting (useful for CI). - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `force` (*boolean*) - Delete without prompting (useful for CI). **Examples** @@ -105,10 +105,10 @@ netlify sites:list **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `json` (*boolean*) - Output project data as JSON - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - Output project data as JSON --- ## `sites:search` @@ -127,10 +127,10 @@ netlify sites:search **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `json` (*boolean*) - Output project data as JSON - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - Output project data as JSON **Examples** diff --git a/docs/commands/status.md b/docs/commands/status.md index 9529891046a..8a76f13fe28 100644 --- a/docs/commands/status.md +++ b/docs/commands/status.md @@ -18,10 +18,10 @@ netlify status **Flags** -- `json` (*boolean*) - Output status information as JSON -- `verbose` (*boolean*) - Output system info - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `json` (*boolean*) - Output status information as JSON +- `verbose` (*boolean*) - Output system info | Subcommand | description | |:--------------------------- |:-----| @@ -41,9 +41,9 @@ netlify status:hooks **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in --- diff --git a/docs/commands/switch.md b/docs/commands/switch.md index 8d08756d7c1..1668f2bf1e7 100644 --- a/docs/commands/switch.md +++ b/docs/commands/switch.md @@ -18,9 +18,9 @@ netlify switch **Flags** -- `email` (*string*) - Switch to the account matching this email address - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `email` (*string*) - Switch to the account matching this email address <!-- AUTO-GENERATED-CONTENT:END --> diff --git a/docs/commands/teams.md b/docs/commands/teams.md index db255b2e877..0d35bb5c3e6 100644 --- a/docs/commands/teams.md +++ b/docs/commands/teams.md @@ -46,10 +46,10 @@ netlify teams:list **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `json` (*boolean*) - Output team data as JSON - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - Output team data as JSON **Examples** diff --git a/docs/commands/unlink.md b/docs/commands/unlink.md index 030a0506d15..c44e1f780e9 100644 --- a/docs/commands/unlink.md +++ b/docs/commands/unlink.md @@ -18,9 +18,9 @@ netlify unlink **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in <!-- AUTO-GENERATED-CONTENT:END --> diff --git a/docs/commands/watch.md b/docs/commands/watch.md index ac7062847d5..95a4c45727d 100644 --- a/docs/commands/watch.md +++ b/docs/commands/watch.md @@ -18,9 +18,9 @@ netlify watch **Flags** -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in **Examples** diff --git a/src/commands/agents/agents-commit.ts b/src/commands/agents/agents-commit.ts index 109e5064f41..660cc4777a9 100644 --- a/src/commands/agents/agents-commit.ts +++ b/src/commands/agents/agents-commit.ts @@ -29,7 +29,9 @@ export const agentsCommit = async (id: string, options: AgentCommitOptions, comm await command.authenticate() const { siteInfo } = command.netlify if (!siteInfo.build_settings?.repo_url) { - return logAndThrowError('This project is not connected to a git repository. Commits are only available for git-backed projects.') + return logAndThrowError( + 'This project is not connected to a git repository. Commits are only available for git-backed projects.', + ) } const api = createAgentsApi(command.netlify) diff --git a/src/commands/agents/agents-create.ts b/src/commands/agents/agents-create.ts index a2d47a1ce7f..f2e0d05139d 100644 --- a/src/commands/agents/agents-create.ts +++ b/src/commands/agents/agents-create.ts @@ -69,7 +69,9 @@ const detectLocalGit = (): LocalGitInfo => { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', }) - hasUnpushedCommits = Number.parseInt(result.stdout.trim(), 10) > 0 + if (result.status === 0) { + hasUnpushedCommits = Number.parseInt(result.stdout.trim(), 10) > 0 + } } } catch { // No upstream configured: can't tell. diff --git a/src/commands/agents/agents-list.ts b/src/commands/agents/agents-list.ts index 833d4833e2e..2ea1b7ad54f 100644 --- a/src/commands/agents/agents-list.ts +++ b/src/commands/agents/agents-list.ts @@ -22,8 +22,13 @@ interface AgentListOptions extends OptionValues { account?: string } +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/ + const toUnixSeconds = (input?: string): number | undefined => { if (!input) return undefined + if (!ISO_DATE_PATTERN.test(input)) { + throw new Error(`Invalid date "${input}". Use an ISO timestamp like 2026-05-01T00:00:00Z.`) + } const parsed = Date.parse(input) if (Number.isNaN(parsed)) { throw new Error(`Invalid date "${input}". Use an ISO timestamp like 2026-05-01T00:00:00Z.`) @@ -56,7 +61,9 @@ const buildFilters = (options: AgentListOptions): ListAgentRunnersFilters => { filters.page = parsePositiveInt(options.page, 'page') const perPage = parsePositiveInt(options.perPage, 'per-page') if (perPage !== undefined && perPage > MAX_PER_PAGE) { - throw new Error(`--per-page must be ${MAX_PER_PAGE.toString()} or fewer (the server caps at ${MAX_PER_PAGE.toString()})`) + throw new Error( + `--per-page must be ${MAX_PER_PAGE.toString()} or fewer (the server caps at ${MAX_PER_PAGE.toString()})`, + ) } filters.per_page = perPage return filters @@ -103,7 +110,8 @@ export const agentsList = async (options: AgentListOptions, command: BaseCommand } if (result.data.length === 0) { - log(chalk.yellow('No agent tasks found for this site.')) + const emptyScope = options.account ? `account ${options.account}` : 'this site' + log(chalk.yellow(`No agent tasks found for ${emptyScope}.`)) log() log(`Create your first agent task with:`) log(` ${chalk.cyan('netlify agents:create')}`) @@ -113,11 +121,18 @@ export const agentsList = async (options: AgentListOptions, command: BaseCommand const isGitBased = Boolean(siteInfo.build_settings?.repo_branch) const scope = options.account ? `account ${options.account}` : siteInfo.name const table = new AsciiTable(`Agent Tasks for ${scope}`) - const baseColumnLabel = isGitBased ? 'BRANCH' : 'BASE' + // Account-wide listing spans multiple sites, so we can't infer a single base column from the linked site. + const baseColumnLabel = options.account ? 'BRANCH/BASE' : isGitBased ? 'BRANCH' : 'BASE' table.setHeading('ID', 'STATUS', 'PROMPT', baseColumnLabel, 'DURATION', 'CREATED') for (const runner of result.data) { - const baseValue = isGitBased ? truncateText(runner.branch ?? 'unknown', 12) : 'Production' + const baseValue = options.account + ? runner.branch + ? truncateText(runner.branch, 12) + : 'Production' + : isGitBased + ? truncateText(runner.branch ?? 'unknown', 12) + : 'Production' table.addRow( runner.id, (runner.state ?? 'unknown').toUpperCase(), diff --git a/src/commands/agents/agents-pr.ts b/src/commands/agents/agents-pr.ts index 5397866df09..629d87eb9c3 100644 --- a/src/commands/agents/agents-pr.ts +++ b/src/commands/agents/agents-pr.ts @@ -14,7 +14,9 @@ export const agentsPullRequest = async (id: string, options: AgentPrOptions, com await command.authenticate() const { siteInfo } = command.netlify if (!siteInfo.build_settings?.repo_url) { - return logAndThrowError('This project is not connected to a git repository. Pull requests are only available for git-backed projects.') + return logAndThrowError( + 'This project is not connected to a git repository. Pull requests are only available for git-backed projects.', + ) } const api = createAgentsApi(command.netlify) diff --git a/src/commands/agents/agents-show.ts b/src/commands/agents/agents-show.ts index c558e7a5180..c2bf0f2d151 100644 --- a/src/commands/agents/agents-show.ts +++ b/src/commands/agents/agents-show.ts @@ -428,9 +428,17 @@ const watchAgentTask = async (api: AgentsApi, id: string, command: BaseCommand) consecutiveFailures += 1 if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { renderer.stop() - return logAndThrowError(`Watch aborted after ${MAX_CONSECUTIVE_FAILURES.toString()} consecutive polling failures: ${error.message}`) + return logAndThrowError( + `Watch aborted after ${MAX_CONSECUTIVE_FAILURES.toString()} consecutive polling failures: ${error.message}`, + ) } - renderer.print(`${chalk.yellow('!')} ${chalk.dim(`poll failed (${consecutiveFailures.toString()}/${MAX_CONSECUTIVE_FAILURES.toString()}): ${error.message}, retrying`)}`) + renderer.print( + `${chalk.yellow('!')} ${chalk.dim( + `poll failed (${consecutiveFailures.toString()}/${MAX_CONSECUTIVE_FAILURES.toString()}): ${ + error.message + }, retrying`, + )}`, + ) } } } finally { diff --git a/src/commands/agents/attachments.ts b/src/commands/agents/attachments.ts index c22033bbd70..fe375f0620c 100644 --- a/src/commands/agents/attachments.ts +++ b/src/commands/agents/attachments.ts @@ -33,7 +33,9 @@ export const uploadAttachments = async ( ): Promise<UploadedAttachment[]> => { if (filePaths.length === 0) return [] if (filePaths.length > MAX_ATTACHMENTS_PER_REQUEST) { - throw new Error(`Too many attachments: ${filePaths.length.toString()} given, max is ${MAX_ATTACHMENTS_PER_REQUEST.toString()}`) + throw new Error( + `Too many attachments: ${filePaths.length.toString()} given, max is ${MAX_ATTACHMENTS_PER_REQUEST.toString()}`, + ) } const resolved = await Promise.all( diff --git a/tests/integration/commands/agents/agents-create.test.ts b/tests/integration/commands/agents/agents-create.test.ts index 6ae89bc2368..1829817ad47 100644 --- a/tests/integration/commands/agents/agents-create.test.ts +++ b/tests/integration/commands/agents/agents-create.test.ts @@ -90,18 +90,13 @@ describe('agents:create command', () => { path: 'agent_runners', method: 'POST' as const, response: mockAgentRunner, - validateRequest: (request: { body: string }) => { - const body = JSON.parse(request.body) as { prompt: string; branch: string } - expect(body.prompt).toBe('Build a contact form') - expect(body.branch).toBe('main') - }, }, ] await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(routes, async ({ apiUrl }) => { + await withMockApi(routes, async ({ apiUrl, requests }) => { const childProcess = execa(cliPath, ['agents:create', '--agent', 'claude', '--branch', 'main'], { cwd: builder.directory, env: { NETLIFY_API_URL: apiUrl, NETLIFY_SITE_ID: 'site_id', NETLIFY_AUTH_TOKEN: 'fake-token' }, @@ -118,6 +113,8 @@ describe('agents:create command', () => { expect(result.stdout).toContain('Agent task created successfully!') expect(result.stdout).toContain('Prompt: Build a contact form') + const post = requests.find((r) => r.method === 'POST' && r.path.endsWith('/agent_runners')) + expect(post?.body).toMatchObject({ prompt: 'Build a contact form', branch: 'main' }) }) }) }) @@ -199,19 +196,13 @@ describe('agents:create command', () => { path: 'agent_runners', method: 'POST' as const, response: mockAgentRunnerNoRepo, - // Verify that no branch is sent in the request - validateRequest: (request: { body: string }) => { - const body = JSON.parse(request.body) as { prompt: string; branch?: string } - expect(body).not.toHaveProperty('branch') - expect(body.prompt).toBe('Add a contact form') - }, }, ] await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(routes, async ({ apiUrl }) => { + await withMockApi(routes, async ({ apiUrl, requests }) => { const { stdout } = await execa(cliPath, ['agents:create', 'Add a contact form', '--agent', 'claude'], { cwd: builder.directory, env: { NETLIFY_API_URL: apiUrl, NETLIFY_SITE_ID: 'zip_site_id', NETLIFY_AUTH_TOKEN: 'fake-token' }, @@ -223,6 +214,9 @@ describe('agents:create command', () => { expect(stdout).toContain('Agent: Claude') expect(stdout).toContain('Base: Latest production deployment') expect(stdout).not.toContain('Branch:') + const post = requests.find((r) => r.method === 'POST' && r.path.endsWith('/agent_runners')) + expect(post?.body).toMatchObject({ prompt: 'Add a contact form' }) + expect(post?.body).not.toHaveProperty('branch') }) }) }) @@ -234,19 +228,13 @@ describe('agents:create command', () => { path: 'agent_runners', method: 'POST' as const, response: mockAgentRunner, - // Verify that branch is sent in the request - validateRequest: (request: { body: string }) => { - const body = JSON.parse(request.body) as { prompt: string; branch: string } - expect(body.branch).toBe('feature-branch') - expect(body.prompt).toBe('Create a dashboard') - }, }, ] await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(routes, async ({ apiUrl }) => { + await withMockApi(routes, async ({ apiUrl, requests }) => { const { stdout } = await execa( cliPath, ['agents:create', 'Create a dashboard', '--agent', 'claude', '--branch', 'feature-branch'], @@ -259,6 +247,8 @@ describe('agents:create command', () => { expect(stdout).toContain('Agent task created successfully!') expect(stdout).toContain('Branch: feature-branch') expect(stdout).not.toContain('Base: Latest production deployment') + const post = requests.find((r) => r.method === 'POST' && r.path.endsWith('/agent_runners')) + expect(post?.body).toMatchObject({ prompt: 'Create a dashboard', branch: 'feature-branch' }) }) }) }) @@ -270,17 +260,13 @@ describe('agents:create command', () => { path: 'agent_runners', method: 'POST' as const, response: { ...mockAgentRunner, branch: 'develop' }, - validateRequest: (request: { body: string }) => { - const body = JSON.parse(request.body) as { branch: string } - expect(body.branch).toBe('develop') - }, }, ] await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(routes, async ({ apiUrl }) => { + await withMockApi(routes, async ({ apiUrl, requests }) => { const childProcess = execa(cliPath, ['agents:create', 'Create a form', '--agent', 'claude'], { cwd: builder.directory, env: { NETLIFY_API_URL: apiUrl, NETLIFY_SITE_ID: 'site_id', NETLIFY_AUTH_TOKEN: 'fake-token' }, @@ -297,6 +283,8 @@ describe('agents:create command', () => { expect(result.stdout).toContain('Agent task created successfully!') expect(result.stdout).toContain('Branch: develop') + const post = requests.find((r) => r.method === 'POST' && r.path.endsWith('/agent_runners')) + expect(post?.body).toMatchObject({ branch: 'develop' }) }) }) }) diff --git a/tests/integration/commands/agents/agents-rename.test.ts b/tests/integration/commands/agents/agents-rename.test.ts index f529c335648..f50215850f2 100644 --- a/tests/integration/commands/agents/agents-rename.test.ts +++ b/tests/integration/commands/agents/agents-rename.test.ts @@ -30,17 +30,13 @@ describe('agents:rename command', () => { path: 'agent_runners/test_id', method: 'PATCH' as const, response: renamed, - validateRequest: (request: { body: string }) => { - const body = JSON.parse(request.body) as { title: string } - expect(body.title).toBe('New title') - }, }, ] await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(routes, async ({ apiUrl }) => { + await withMockApi(routes, async ({ apiUrl, requests }) => { const cliResponse = (await callCli( ['agents:rename', 'test_id', 'New title'], getCLIOptions({ apiUrl, builder }), @@ -48,6 +44,8 @@ describe('agents:rename command', () => { expect(cliResponse).toContain('Agent task renamed.') expect(cliResponse).toContain('Title: New title') + const patch = requests.find((r) => r.method === 'PATCH' && r.path.endsWith('/agent_runners/test_id')) + expect(patch?.body).toEqual({ title: 'New title' }) }) }) }) @@ -60,23 +58,21 @@ describe('agents:rename command', () => { path: 'agent_runners/test_id', method: 'PATCH' as const, response: renamed, - validateRequest: (request: { body: string }) => { - const body = JSON.parse(request.body) as { title: string } - expect(body.title).toBe('Trimmed title') - }, }, ] await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(routes, async ({ apiUrl }) => { + await withMockApi(routes, async ({ apiUrl, requests }) => { const cliResponse = (await callCli( ['agents:rename', 'test_id', ' Trimmed title '], getCLIOptions({ apiUrl, builder }), )) as string expect(cliResponse).toContain('Title: Trimmed title') + const patch = requests.find((r) => r.method === 'PATCH' && r.path.endsWith('/agent_runners/test_id')) + expect(patch?.body).toEqual({ title: 'Trimmed title' }) }) }) }) @@ -114,17 +110,13 @@ describe('agents:rename command', () => { path: 'agent_runners/test_id', method: 'PATCH' as const, response: renamed, - validateRequest: (request: { body: string }) => { - const body = JSON.parse(request.body) as { title: string } - expect(body.title).toBe('Clean title') - }, }, ] await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(routes, async ({ apiUrl }) => { + await withMockApi(routes, async ({ apiUrl, requests }) => { const tagChar = String.fromCodePoint(0xe0041) const cliResponse = (await callCli( ['agents:rename', 'test_id', `Clean${tagChar} title`], @@ -132,6 +124,8 @@ describe('agents:rename command', () => { )) as string expect(cliResponse).toContain('Agent task renamed') + const patch = requests.find((r) => r.method === 'PATCH' && r.path.endsWith('/agent_runners/test_id')) + expect(patch?.body).toEqual({ title: 'Clean title' }) }) }) }) From dd7f7ea323e5a7ec6d1c0cbae3479ea983902fd5 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya <vaibhavacharya111@gmail.com> Date: Mon, 11 May 2026 17:08:34 +0530 Subject: [PATCH 16/18] chore(docs): regenerate docs/commands with Node 24 (matches CI) The sortOptions comparator in src/utils/command-helpers.ts isn't a valid comparator (returns -1 unconditionally when either side is a base flag), so the output ordering depends on the sort engine. CI uses Node 24; my local Node 26 was producing a different ordering. Regenerate with Node 24 so verify-docs passes. --- docs/commands/agents.md | 46 ++++++++++++++++++------------------- docs/commands/api.md | 4 ++-- docs/commands/blobs.md | 18 +++++++-------- docs/commands/build.md | 4 ++-- docs/commands/claim.md | 4 ++-- docs/commands/clone.md | 4 ++-- docs/commands/completion.md | 2 +- docs/commands/database.md | 36 ++++++++++++++--------------- docs/commands/dev.md | 4 ++-- docs/commands/env.md | 18 +++++++-------- docs/commands/functions.md | 14 +++++------ docs/commands/init.md | 4 ++-- docs/commands/link.md | 4 ++-- docs/commands/login.md | 4 ++-- docs/commands/open.md | 8 +++---- docs/commands/recipes.md | 4 ++-- docs/commands/sites.md | 14 +++++------ docs/commands/status.md | 6 ++--- docs/commands/switch.md | 2 +- docs/commands/teams.md | 4 ++-- docs/commands/unlink.md | 2 +- docs/commands/watch.md | 2 +- 22 files changed, 104 insertions(+), 104 deletions(-) diff --git a/docs/commands/agents.md b/docs/commands/agents.md index 9cd3f70774d..db5d3235bc9 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -21,9 +21,9 @@ netlify agents **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -72,12 +72,12 @@ netlify agents:archive **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `yes` (*boolean*) - skip confirmation prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -103,11 +103,11 @@ netlify agents:commit **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `branch` (*string*) - target branch to commit to - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `project` (*string*) - project ID or name (if not in a linked directory) **Examples** @@ -286,10 +286,10 @@ netlify agents:open **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `project` (*string*) - project ID or name (if not in a linked directory) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -316,11 +316,11 @@ netlify agents:pr **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -345,13 +345,13 @@ netlify agents:publish **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - publish even when the run is out of sync with production - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `yes` (*boolean*) - skip confirmation prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -378,12 +378,12 @@ netlify agents:redeploy **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `session` (*string*) - redeploy a specific session (defaults to the latest completed one) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -410,11 +410,11 @@ netlify agents:rename **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -439,13 +439,13 @@ netlify agents:revert **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `session` (*string*) - session ID to revert to - `yes` (*boolean*) - skip confirmation prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -470,13 +470,13 @@ netlify agents:show **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `session` (*string*) - show details of a specific session within the task - `watch` (*boolean*) - poll until the task reaches a terminal state +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -503,13 +503,13 @@ netlify agents:stop **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `session` (*string*) - stop a single session instead of the entire task - `yes` (*boolean*) - skip confirmation prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -535,12 +535,12 @@ netlify agents:sync **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - output result as JSON - `project` (*string*) - project ID or name (if not in a linked directory) - `yes` (*boolean*) - skip confirmation prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/api.md b/docs/commands/api.md index f5b74c676f8..a5561037f92 100644 --- a/docs/commands/api.md +++ b/docs/commands/api.md @@ -24,10 +24,10 @@ netlify api **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `data` (*string*) - Data to use - `list` (*boolean*) - List out available API methods +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/blobs.md b/docs/commands/blobs.md index a9c6b3d82cb..a0a6fb52b4f 100644 --- a/docs/commands/blobs.md +++ b/docs/commands/blobs.md @@ -18,9 +18,9 @@ netlify blobs **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -59,10 +59,10 @@ netlify blobs:delete **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Bypasses prompts & Force the command to run. +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- ## `blobs:get` @@ -82,10 +82,10 @@ netlify blobs:get **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `output` (*string*) - Defines the filesystem path where the blob data should be persisted +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- ## `blobs:list` @@ -104,12 +104,12 @@ netlify blobs:list **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `directories` (*boolean*) - Indicates that keys with the '/' character should be treated as directories, returning a list of sub-directories at a given level rather than all the keys inside them - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output list contents as JSON - `prefix` (*string*) - A string for filtering down the entries; when specified, only the entries whose key starts with that prefix are returned +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- ## `blobs:set` @@ -130,11 +130,11 @@ netlify blobs:set **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Bypasses prompts & Force the command to run. - `input` (*string*) - Defines the filesystem path where the blob data should be read from +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- diff --git a/docs/commands/build.md b/docs/commands/build.md index 1da787629f2..2b345292b36 100644 --- a/docs/commands/build.md +++ b/docs/commands/build.md @@ -17,11 +17,11 @@ netlify build **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `context` (*string*) - Specify a deploy context for environment variables read during the build (”production”, ”deploy-preview”, ”branch-deploy”, ”dev”) or `branch:your-branch` where `your-branch` is the name of a branch (default: value of CONTEXT or ”production”) - `dry` (*boolean*) - Dry run: show instructions without running them - `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `offline` (*boolean*) - Disables any features that require network access **Examples** diff --git a/docs/commands/claim.md b/docs/commands/claim.md index bbfda497427..74a43631e45 100644 --- a/docs/commands/claim.md +++ b/docs/commands/claim.md @@ -17,11 +17,11 @@ netlify claim **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `site` (*string*) - The site ID of the anonymous deploy to claim (required) - `token` (*string*) - The drop token provided when the site was deployed (required) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/clone.md b/docs/commands/clone.md index c82806c8769..d67d38b62b5 100644 --- a/docs/commands/clone.md +++ b/docs/commands/clone.md @@ -28,11 +28,11 @@ netlify clone **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `id` (*string*) - ID of existing Netlify project to link to - `name` (*string*) - Name of existing Netlify project to link to +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/completion.md b/docs/commands/completion.md index c460b785bfb..71e9ee5d967 100644 --- a/docs/commands/completion.md +++ b/docs/commands/completion.md @@ -46,9 +46,9 @@ netlify completion:install **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in --- diff --git a/docs/commands/database.md b/docs/commands/database.md index 0c15d9f8001..b3de82e4d64 100644 --- a/docs/commands/database.md +++ b/docs/commands/database.md @@ -19,9 +19,9 @@ netlify database **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -55,11 +55,11 @@ netlify database status **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `branch` (*string*) - Netlify branch name to query; defaults to the local development database - `json` (*boolean*) - Output result as JSON - `show-credentials` (*boolean*) - Include the full connection string (including username and password) in the output +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -83,10 +83,10 @@ netlify database init **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `yes` (*boolean*) - Non-interactive mode. Accepts the defaults for every prompt. +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -108,11 +108,11 @@ netlify database connect **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output query results as JSON. When used without --query, prints the connection details as JSON instead. - `query` (*string*) - Execute a single query and exit +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -136,10 +136,10 @@ netlify database reset **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output result as JSON +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- ## `database migrations` @@ -154,9 +154,9 @@ netlify database migrations **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -179,11 +179,11 @@ netlify database migrations apply **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output result as JSON - `to` (*string*) - Target migration name or prefix to apply up to (applies all if omitted) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- ## `database migrations new` @@ -198,12 +198,12 @@ netlify database migrations new **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `description` (*string*) - Purpose of the migration (used to generate the file name) - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output result as JSON - `scheme` (*timestamp | sequential*) - Numbering scheme for migration prefixes +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -225,11 +225,11 @@ netlify database migrations pull **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `branch` (*string*) - Pull migrations for a specific branch (defaults to 'production'; pass --branch with no value to use local git branch) - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Skip confirmation prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `json` (*boolean*) - Output result as JSON **Examples** @@ -254,11 +254,11 @@ netlify database migrations reset **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `branch` (*string*) - Target a remote preview branch instead of the local development database - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output result as JSON +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/dev.md b/docs/commands/dev.md index a873f732a89..cba068156ce 100644 --- a/docs/commands/dev.md +++ b/docs/commands/dev.md @@ -78,10 +78,10 @@ netlify dev:exec **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `context` (*string*) - Specify a deploy context for environment variables (”production”, ”deploy-preview”, ”branch-deploy”, ”dev”) or `branch:your-branch` where `your-branch` is the name of a branch (default: dev) - `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/env.md b/docs/commands/env.md index 489a1de7a8d..930790b7fbb 100644 --- a/docs/commands/env.md +++ b/docs/commands/env.md @@ -18,9 +18,9 @@ netlify env **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -56,12 +56,12 @@ netlify env:clone **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Bypasses prompts & Force the command to run. - `from` (*string*) - Project ID (From) - `to` (*string*) - Project ID (To) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -87,12 +87,12 @@ netlify env:get **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `context` (*string*) - Specify a deploy context for environment variables (”production”, ”deploy-preview”, ”branch-deploy”, ”dev”) or `branch:your-branch` where `your-branch` is the name of a branch - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output environment variables as JSON - `scope` (*builds | functions | post-processing | runtime | any*) - Specify a scope +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `site` (*string*) - A project name or ID to target **Examples** @@ -121,11 +121,11 @@ netlify env:import **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output environment variables as JSON - `replace-existing` (*boolean*) - Replace all existing variables instead of merging them with the current ones +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `site` (*string*) - A project name or ID to target --- @@ -218,12 +218,12 @@ netlify env:unset **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `context` (*string*) - Specify a deploy context for environment variables (”production”, ”deploy-preview”, ”branch-deploy”, ”dev”) or `branch:your-branch` where `your-branch` is the name of a branch (default: all contexts) - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Bypasses prompts & Force the command to run. - `json` (*boolean*) - Output environment variables as JSON +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `site` (*string*) - A project name or ID to target **Examples** diff --git a/docs/commands/functions.md b/docs/commands/functions.md index f725a9ed79d..9a560a54e0f 100644 --- a/docs/commands/functions.md +++ b/docs/commands/functions.md @@ -19,9 +19,9 @@ netlify functions **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -52,11 +52,11 @@ netlify functions:build **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `functions` (*string*) - Specify a functions directory to build to - `src` (*string*) - Specify the source directory for the functions +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- ## `functions:create` @@ -151,11 +151,11 @@ netlify functions:list **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `functions` (*string*) - Specify a functions directory to list - `json` (*boolean*) - Output function data as JSON +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- ## `functions:serve` @@ -170,12 +170,12 @@ netlify functions:serve **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `functions` (*string*) - Specify a functions directory to serve - `offline` (*boolean*) - Disables any features that require network access - `port` (*string*) - Specify a port for the functions server +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- diff --git a/docs/commands/init.md b/docs/commands/init.md index 7521d8b24c0..9aa21e7e4fe 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -18,12 +18,12 @@ netlify init **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Reinitialize CI hooks if the linked project is already configured to use CI - `git-remote-name` (*string*) - Name of Git remote to use. e.g. "origin" - `manual` (*boolean*) - Manually configure a git remote for CI +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in <!-- AUTO-GENERATED-CONTENT:END --> diff --git a/docs/commands/link.md b/docs/commands/link.md index f7ad7656510..5d1b233e7b6 100644 --- a/docs/commands/link.md +++ b/docs/commands/link.md @@ -18,13 +18,13 @@ netlify link **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `git-remote-name` (*string*) - Name of Git remote to use. e.g. "origin" - `git-remote-url` (*string*) - URL of the repository (or Github `owner/repo`) to link to - `id` (*string*) - ID of project to link to - `name` (*string*) - Name of project to link to +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/login.md b/docs/commands/login.md index 4ad07638734..6304e5dceb4 100644 --- a/docs/commands/login.md +++ b/docs/commands/login.md @@ -19,12 +19,12 @@ netlify login **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `check` (*string*) - Check the status of a login ticket created with --request - `json` (*boolean*) - Output as JSON (for use with --request or --check) - `new` (*boolean*) - Login to new Netlify account - `request` (*string*) - Create a login ticket for agent/human-in-the-loop auth +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in <!-- AUTO-GENERATED-CONTENT:END --> diff --git a/docs/commands/open.md b/docs/commands/open.md index 1accfa6af95..fc15acdd7a9 100644 --- a/docs/commands/open.md +++ b/docs/commands/open.md @@ -18,10 +18,10 @@ netlify open **Flags** - `admin` (*boolean*) - Open Netlify project -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `site` (*boolean*) - Open project +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in | Subcommand | description | |:--------------------------- |:-----| @@ -51,9 +51,9 @@ netlify open:admin **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in **Examples** @@ -74,9 +74,9 @@ netlify open:site **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in **Examples** diff --git a/docs/commands/recipes.md b/docs/commands/recipes.md index 29f5ddbd8a7..dcd1ed9ced1 100644 --- a/docs/commands/recipes.md +++ b/docs/commands/recipes.md @@ -21,9 +21,9 @@ netlify recipes **Flags** +- `name` (*string*) - recipe name to use - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `name` (*string*) - recipe name to use | Subcommand | description | |:--------------------------- |:-----| @@ -50,9 +50,9 @@ netlify recipes:list **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in **Examples** diff --git a/docs/commands/sites.md b/docs/commands/sites.md index 7ad1e0ef833..37dfa2281df 100644 --- a/docs/commands/sites.md +++ b/docs/commands/sites.md @@ -19,9 +19,9 @@ netlify sites **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in | Subcommand | description | |:--------------------------- |:-----| @@ -81,10 +81,10 @@ netlify sites:delete **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Delete without prompting (useful for CI). +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** @@ -105,10 +105,10 @@ netlify sites:list **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output project data as JSON +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in --- ## `sites:search` @@ -127,10 +127,10 @@ netlify sites:search **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output project data as JSON +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/status.md b/docs/commands/status.md index 8a76f13fe28..9529891046a 100644 --- a/docs/commands/status.md +++ b/docs/commands/status.md @@ -18,10 +18,10 @@ netlify status **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `json` (*boolean*) - Output status information as JSON - `verbose` (*boolean*) - Output system info +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in | Subcommand | description | |:--------------------------- |:-----| @@ -41,9 +41,9 @@ netlify status:hooks **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in --- diff --git a/docs/commands/switch.md b/docs/commands/switch.md index 1668f2bf1e7..8d08756d7c1 100644 --- a/docs/commands/switch.md +++ b/docs/commands/switch.md @@ -18,9 +18,9 @@ netlify switch **Flags** +- `email` (*string*) - Switch to the account matching this email address - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `email` (*string*) - Switch to the account matching this email address <!-- AUTO-GENERATED-CONTENT:END --> diff --git a/docs/commands/teams.md b/docs/commands/teams.md index 0d35bb5c3e6..db255b2e877 100644 --- a/docs/commands/teams.md +++ b/docs/commands/teams.md @@ -46,10 +46,10 @@ netlify teams:list **Flags** -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `json` (*boolean*) - Output team data as JSON +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** diff --git a/docs/commands/unlink.md b/docs/commands/unlink.md index c44e1f780e9..030a0506d15 100644 --- a/docs/commands/unlink.md +++ b/docs/commands/unlink.md @@ -18,9 +18,9 @@ netlify unlink **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in <!-- AUTO-GENERATED-CONTENT:END --> diff --git a/docs/commands/watch.md b/docs/commands/watch.md index 95a4c45727d..ac7062847d5 100644 --- a/docs/commands/watch.md +++ b/docs/commands/watch.md @@ -18,9 +18,9 @@ netlify watch **Flags** +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in **Examples** From b8fa5c079826960cf5077e7d050950ab89cbb325 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya <vaibhavacharya111@gmail.com> Date: Tue, 12 May 2026 13:09:46 +0530 Subject: [PATCH 17/18] refactor(agents): align api.ts method names with bitballoon @operation_id --- src/commands/agents/agents-rename.ts | 2 +- src/commands/agents/agents-revert.ts | 2 +- src/commands/agents/agents-stop.ts | 4 ++-- src/commands/agents/api.ts | 30 ++++++++++++++++------------ src/commands/agents/attachments.ts | 7 +++++-- 5 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/commands/agents/agents-rename.ts b/src/commands/agents/agents-rename.ts index 3a805530d7e..f84531f0a85 100644 --- a/src/commands/agents/agents-rename.ts +++ b/src/commands/agents/agents-rename.ts @@ -21,7 +21,7 @@ export const agentsRename = async (id: string, title: string, options: AgentRena const spinner = startSpinner({ text: 'Renaming agent task...' }) try { - const runner = await api.renameAgentRunner(id, sanitized) + const runner = await api.updateAgentRunner(id, { title: sanitized }) stopSpinner({ spinner }) if (options.json) { diff --git a/src/commands/agents/agents-revert.ts b/src/commands/agents/agents-revert.ts index 19f64b5dddb..40f426ad0ec 100644 --- a/src/commands/agents/agents-revert.ts +++ b/src/commands/agents/agents-revert.ts @@ -35,7 +35,7 @@ export const agentsRevert = async (id: string, options: AgentRevertOptions, comm const spinner = startSpinner({ text: 'Reverting agent task...' }) try { - const runner = await api.agentRunnerRevert(id, options.session) + const runner = await api.revertAgentRunner(id, options.session) stopSpinner({ spinner }) if (options.json) { diff --git a/src/commands/agents/agents-stop.ts b/src/commands/agents/agents-stop.ts index cb95ab5f030..df4350ec1af 100644 --- a/src/commands/agents/agents-stop.ts +++ b/src/commands/agents/agents-stop.ts @@ -51,7 +51,7 @@ const stopRunner = async (api: ReturnType<typeof createAgentsApi>, id: string, o const stopSpin = startSpinner({ text: 'Stopping agent task...' }) try { - await api.stopAgentRunner(id) + await api.deleteAgentRunner(id) stopSpinner({ spinner: stopSpin }) } catch (error_) { stopSpinner({ spinner: stopSpin, error: true }) @@ -106,7 +106,7 @@ const stopSession = async ( const stopSpin = startSpinner({ text: 'Stopping session...' }) try { - await api.stopAgentRunnerSession(id, sessionId) + await api.deleteAgentRunnerSession(id, sessionId) stopSpinner({ spinner: stopSpin }) } catch (error_) { stopSpinner({ spinner: stopSpin, error: true }) diff --git a/src/commands/agents/api.ts b/src/commands/agents/api.ts index 0e3c0883079..01594d9ebc3 100644 --- a/src/commands/agents/api.ts +++ b/src/commands/agents/api.ts @@ -1,3 +1,7 @@ +// TODO: Migrate to @netlify/api once these endpoints are public. +// They are marked x-internal in bitballoon (#21736). +// Method names mirror the bitballoon @operation_id to keep the future swap quick. + import type BaseCommand from '../base-command.js' import { parseLinkHeader } from './utils.js' import type { @@ -122,7 +126,7 @@ export const createAgentsApi = (netlify: NetlifyContext) => { return requestJson<AgentRunner>(`/agent_runners?${params.toString()}`, jsonInit('POST', payload)) } - const stopAgentRunner = (id: string): Promise<void> => + const deleteAgentRunner = (id: string): Promise<void> => requestNoContent(`/agent_runners/${id}`, { method: 'DELETE', headers: baseHeaders() }) const archiveAgentRunner = (id: string): Promise<void> => @@ -147,7 +151,7 @@ export const createAgentsApi = (netlify: NetlifyContext) => { ): Promise<AgentRunnerSession> => requestJson<AgentRunnerSession>(`/agent_runners/${id}/sessions`, jsonInit('POST', payload)) - const stopAgentRunnerSession = (id: string, sessionId: string): Promise<void> => + const deleteAgentRunnerSession = (id: string, sessionId: string): Promise<void> => requestNoContent(`/agent_runners/${id}/sessions/${sessionId}`, { method: 'DELETE', headers: baseHeaders(), @@ -187,11 +191,11 @@ export const createAgentsApi = (netlify: NetlifyContext) => { const agentRunnerPublishToProduction = (id: string): Promise<AgentRunner> => requestJson<AgentRunner>(`/agent_runners/${id}/publish_to_production`, jsonInit('POST')) - const agentRunnerRevert = (id: string, sessionId: string): Promise<AgentRunner> => + const revertAgentRunner = (id: string, sessionId: string): Promise<AgentRunner> => requestJson<AgentRunner>(`/agent_runners/${id}/revert`, jsonInit('POST', { session_id: sessionId })) - const renameAgentRunner = (id: string, title: string): Promise<AgentRunner> => - requestJson<AgentRunner>(`/agent_runners/${id}`, jsonInit('PATCH', { title })) + const updateAgentRunner = (id: string, payload: { title?: string; base_deploy_id?: string }): Promise<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}`, jsonInit('PATCH', payload)) const rebaseAgentRunner = (id: string): Promise<AgentRunner> => requestJson<AgentRunner>(`/agent_runners/${id}/rebase`, jsonInit('POST')) @@ -202,14 +206,14 @@ export const createAgentsApi = (netlify: NetlifyContext) => { const syncGitOriginAgentRunner = (id: string): Promise<AgentRunner> => requestJson<AgentRunner>(`/agent_runners/${id}/sync_git_origin`, jsonInit('POST')) - const createUploadUrl = (payload: { + const createAgentRunnerUploadUrl = (payload: { account_id: string filename: string content_type: string }): Promise<UploadUrlResponse> => requestJson<UploadUrlResponse>(`/agent_runners/upload_url`, jsonInit('POST', payload)) - const createDeleteUrl = (payload: { account_id: string; file_key: string }): Promise<DeleteUrlResponse> => + const createAgentRunnerDeleteUrl = (payload: { account_id: string; file_key: string }): Promise<DeleteUrlResponse> => requestJson<DeleteUrlResponse>(`/agent_runners/delete_url`, jsonInit('POST', payload)) let providersCache: AiGatewayProvidersResponse | null = null @@ -228,12 +232,13 @@ export const createAgentsApi = (netlify: NetlifyContext) => { listAgentRunnersForAccount, getAgentRunner, createAgentRunner, - stopAgentRunner, + updateAgentRunner, + deleteAgentRunner, archiveAgentRunner, listAgentRunnerSessions, getAgentRunnerSession, createAgentRunnerSession, - stopAgentRunnerSession, + deleteAgentRunnerSession, redeployAgentRunnerSession, getAgentRunnerDiff, getSessionResultDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'result'), @@ -241,13 +246,12 @@ export const createAgentsApi = (netlify: NetlifyContext) => { agentRunnerPullRequest, agentRunnerCommitToBranch, agentRunnerPublishToProduction, - agentRunnerRevert, - renameAgentRunner, + revertAgentRunner, rebaseAgentRunner, mergeTargetAgentRunner, syncGitOriginAgentRunner, - createUploadUrl, - createDeleteUrl, + createAgentRunnerUploadUrl, + createAgentRunnerDeleteUrl, listAiGatewayProviders, } } diff --git a/src/commands/agents/attachments.ts b/src/commands/agents/attachments.ts index fe375f0620c..e9dc3a832e1 100644 --- a/src/commands/agents/attachments.ts +++ b/src/commands/agents/attachments.ts @@ -17,7 +17,10 @@ const cleanupOrphans = async (api: AgentsApi, accountId: string, fileKeys: strin await Promise.allSettled( fileKeys.map(async (fileKey) => { try { - const { delete_url: deleteUrl } = await api.createDeleteUrl({ account_id: accountId, file_key: fileKey }) + const { delete_url: deleteUrl } = await api.createAgentRunnerDeleteUrl({ + account_id: accountId, + file_key: fileKey, + }) await fetch(deleteUrl, { method: 'DELETE' }) } catch { // Best-effort cleanup; if it fails, the orphan is the user's tenant problem. @@ -60,7 +63,7 @@ export const uploadAttachments = async ( const uploaded: UploadedAttachment[] = [] try { for (const file of resolved) { - const { upload_url: uploadUrl, file_key: fileKey } = await api.createUploadUrl({ + const { upload_url: uploadUrl, file_key: fileKey } = await api.createAgentRunnerUploadUrl({ account_id: accountId, filename: file.filename, content_type: file.contentType, From 7727e26765c219f3d9919ccb8574fd5b746ff53d Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya <vaibhavacharya111@gmail.com> Date: Tue, 12 May 2026 13:38:46 +0530 Subject: [PATCH 18/18] fix(agents): friendlier 404 message when --account lacks agent task access --- src/commands/agents/agents-list.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/commands/agents/agents-list.ts b/src/commands/agents/agents-list.ts index 2ea1b7ad54f..a14a83de550 100644 --- a/src/commands/agents/agents-list.ts +++ b/src/commands/agents/agents-list.ts @@ -153,8 +153,13 @@ export const agentsList = async (options: AgentListOptions, command: BaseCommand return result.data } catch (error_) { - const error = error_ as Error + const error = error_ as Error & { status?: number } stopSpinner({ spinner, error: true }) + if (options.account && error.status === 404) { + return logAndThrowError( + `Agent tasks are not available for account "${options.account}". Check that the slug is correct and that your account has access to agent tasks.`, + ) + } return logAndThrowError(`Failed to list agent tasks: ${error.message}`) } }