diff --git a/docs/commands/agents.md b/docs/commands/agents.md index 1f6cafdee62..738574aff65 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -27,18 +27,91 @@ 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) +- `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:archive 60c7c3b3e7b4a0001f5e4b3a +netlify agents:archive 60c7c3b3e7b4a0001f5e4b3a --yes +``` + +--- +## `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 +132,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 +153,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 +243,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 +442,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 +451,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 +475,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 +484,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..44f8acb5f50 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) diff --git a/src/commands/agents/agents-archive.ts b/src/commands/agents/agents-archive.ts new file mode 100644 index 00000000000..c66d7bd518d --- /dev/null +++ b/src/commands/agents/agents-archive.ts @@ -0,0 +1,53 @@ +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 AgentArchiveOptions extends OptionValues { + json?: boolean + yes?: 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) + + if (!options.yes && !options.json) { + if (!process.stdin.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) + 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..164e24e7ad8 --- /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?.trim() + if (!targetBranch) { + if (!process.stdin.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..767df943ea0 100644 --- a/src/commands/agents/agents-create.ts +++ b/src/commands/agents/agents-create.ts @@ -1,134 +1,149 @@ +import { execSync, spawnSync } 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 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. + } + } + 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') + } - let finalPrompt: string - let agent = initialAgent - let branch = initialBranch + 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) 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, options) } - 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 +152,123 @@ 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 isNonInteractive = (options: AgentCreateOptions): boolean => Boolean(options.json) - return logAndThrowError(`Failed to create agent task: ${error.message}`) +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', + 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 +} + +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', + 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, + 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.')) + } + 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..7337eb31458 --- /dev/null +++ b/src/commands/agents/agents-diff.ts @@ -0,0 +1,124 @@ +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, type AgentsApi } 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 + if (!/^[1-9]\d*$/.test(input)) { + throw new Error(`--${name} must be a positive integer`) + } + 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() + 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) { + await verifyRunnerExists(api, id) + 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 + if (error.message.startsWith('Agent task not found:')) { + return logAndThrowError(error.message) + } + 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) { + await verifyRunnerExists(api, id) + 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 + if (error.message.startsWith('Agent task not found:')) { + return logAndThrowError(error.message) + } + 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..f1461ef4ce0 100644 --- a/src/commands/agents/agents-list.ts +++ b/src/commands/agents/agents-list.ts @@ -1,162 +1,167 @@ 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, 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 + if (!/^[1-9]\d*$/.test(input)) { + throw new Error(`--${name} must be a positive integer`) + } + return Number.parseInt(input, 10) +} + +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 - } - - // 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 }) + return 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') + table.setHeading('ID', 'STATUS', '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(), - 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), 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 escapeRegex = (input: string): string => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - 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${escapeRegex(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..2e2577e9016 --- /dev/null +++ b/src/commands/agents/agents-open.ts @@ -0,0 +1,72 @@ +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] + +const isOpenTarget = (input: string): input is OpenTarget => (VALID_TARGETS as readonly string[]).includes(input) + +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 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 + 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..17b2c8e2449 --- /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.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.') + 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..32a13f1182f --- /dev/null +++ b/src/commands/agents/agents-redeploy.ts @@ -0,0 +1,69 @@ +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 perPage = 100 + const maxPages = 10 + let page = 1 + let latestDone: { id: string } | undefined + 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 + page += 1 + } + stopSpinner({ spinner: lookupSpinner }) + 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..b8e6487d6b3 --- /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.stdin.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..575279355cc 100644 --- a/src/commands/agents/agents-show.ts +++ b/src/commands/agents/agents-show.ts @@ -1,172 +1,449 @@ 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 & { 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}`) + } +} - 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 }) + + if (options.json) { + logJson(session) + return session } - 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)}`) - } - } + renderSessionDetail(session, id, command) + return session + } catch (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}`) + } +} - const isGitBased = Boolean(siteInfo.build_settings?.repo_branch) +const renderAgentTask = (runner: AgentRunner, sessions: AgentRunnerSession[], command: BaseCommand) => { + const { siteInfo, site } = command.netlify - 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')}`) + 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) } + } - log(``) - log(chalk.bold('Task:')) - log(` Prompt: ${chalk.dim(agentRunner.title ?? 'No title')}`) + 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}`) + } + + 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}`) + } + + 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, 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() + + 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) - // 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`)}`) + if (TERMINAL_AGENT_STATES.includes(lastRunner.state as (typeof TERMINAL_AGENT_STATES)[number])) { + break + } + await sleep(POLL_INTERVAL_MS) + 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() + } + + 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..ff7f3e861e6 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.stdin.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..0c5c73c9736 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,154 @@ 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('-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', + '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) + }) + + 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 +273,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..070dd987dd6 --- /dev/null +++ b/src/commands/agents/api.ts @@ -0,0 +1,239 @@ +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 } + 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 => { + 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 ?? DEFAULT_PER_PAGE + 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 + // 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 + 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..ef277fad2b1 --- /dev/null +++ b/src/commands/agents/attachments.ts @@ -0,0 +1,80 @@ +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 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}`, + ) + } + 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 +} 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..6a4d8f9452c --- /dev/null +++ b/tests/integration/commands/agents/agents-archive.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 } 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 with --yes', 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', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Agent task archived.') + expect(cliResponse).toContain('Task ID: test_id') + }) + }) + }) + + 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, + { + 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', '--yes'], 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', '--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") + }) + }) + }) +}) 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..a0247cf0331 --- /dev/null +++ b/tests/integration/commands/agents/agents-diff.test.ts @@ -0,0 +1,169 @@ +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' }, + }, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: { id: 'test_id', state: 'done' }, + }, + ] + + 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..65768730454 --- /dev/null +++ b/tests/integration/commands/agents/agents-follow-up.test.ts @@ -0,0 +1,185 @@ +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/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-list.test.ts b/tests/integration/commands/agents/agents-list.test.ts index 15b4825127a..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) => { @@ -126,11 +109,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() @@ -183,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) => { @@ -213,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) => { 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/commands/agents/agents-show.test.ts b/tests/integration/commands/agents/agents-show.test.ts index 3c49ae63f1f..0cabc174a1a 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] }) }) }) }) @@ -102,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', ) }) }) 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', ) }) }) 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