diff --git a/docs/commands/agents.md b/docs/commands/agents.md index 1f6cafdee62..db5d3235bc9 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -27,10 +27,21 @@ 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:rename`](/commands/agents#agentsrename) | Rename an agent task | +| [`agents:revert`](/commands/agents#agentsrevert) | Revert an agent task to a specific session (sessions after it are discarded) | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent task | -| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent task | +| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent task or session | +| [`agents:sync`](/commands/agents#agentssync) | Sync an agent task with the latest production code or remote git origin | **Examples** @@ -38,7 +49,71 @@ netlify agents ```bash netlify agents:create --prompt "Add a contact form" netlify agents:list --status running -netlify agents:show 60c7c3b3e7b4a0001f5e4b3a +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 +134,13 @@ 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 - `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 - `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,79 @@ 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 +``` + +--- +## `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 (binary is stripped 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) +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - output result as JSON +- `model` (*string*) - override model for this session +- `project` (*string*) - project ID or name (if not in a linked directory) +- `prompt` (*string*) - follow-up prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a "Also add tests" +netlify agents:follow-up 60c7c3b3e7b4a0001f5e4b3a -p "Fix the lint error" ``` --- @@ -91,19 +241,216 @@ 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 (running, done, error, archived) +- `title` (*string*) - filter by title (case-insensitive contains) - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `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 archived +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 +- `force` (*boolean*) - publish even when the run is out of sync with production +- `json` (*boolean*) - output result as JSON +- `project` (*string*) - project ID or name (if not in a linked directory) +- `yes` (*boolean*) - skip confirmation prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a +netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --yes +netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --force +``` + +--- +## `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:rename` + +Rename an agent task + +**Usage** + +```bash +netlify agents:rename +``` + +**Arguments** + +- id - agent task ID +- title - new title for the agent task + +**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:rename 60c7c3b3e7b4a0001f5e4b3a "Add dark mode toggle" +``` + +--- +## `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 +473,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 +482,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 +506,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 +515,38 @@ netlify agents:stop ```bash netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a +netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a --session 70d8... --yes +``` + +--- +## `agents:sync` + +Sync an agent task with the latest production code or remote git origin + +**Usage** + +```bash +netlify agents:sync +``` + +**Arguments** + +- id - agent task ID + +**Flags** + +- `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:sync 60c7c3b3e7b4a0001f5e4b3a +netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a --yes ``` --- diff --git a/docs/index.md b/docs/index.md index 2a0899110d5..bc4678c1e76 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,10 +24,21 @@ 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:rename`](/commands/agents#agentsrename) | Rename an agent task | +| [`agents:revert`](/commands/agents#agentsrevert) | Revert an agent task to a specific session (sessions after it are discarded) | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent task | -| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent task | +| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent task or session | +| [`agents:sync`](/commands/agents#agentssync) | Sync an agent task with the latest production code or remote git origin | ### [api](/commands/api) diff --git a/src/commands/agents/agents-archive.ts b/src/commands/agents/agents-archive.ts new file mode 100644 index 00000000000..898b15f21bf --- /dev/null +++ b/src/commands/agents/agents-archive.ts @@ -0,0 +1,54 @@ +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 & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to archive: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-commit.ts b/src/commands/agents/agents-commit.ts new file mode 100644 index 00000000000..660cc4777a9 --- /dev/null +++ b/src/commands/agents/agents-commit.ts @@ -0,0 +1,99 @@ +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 type { AgentRunner } from './types.js' + +interface AgentCommitOptions extends OptionValues { + branch?: string + json?: boolean +} + +const pickDefaultBranch = (runner: AgentRunner): { branch: string; reason: string } | null => { + const prState = runner.pr_state + const hasOpenPr = runner.pr_url && (prState === 'open' || prState === 'draft') + if (hasOpenPr && runner.pr_branch) { + return { branch: runner.pr_branch, reason: 'updating the existing pull request' } + } + if (runner.branch) { + return { branch: runner.branch, reason: "committing to this agent task's branch" } + } + return null +} + +export const agentsCommit = async (id: string, options: AgentCommitOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + await command.authenticate() + const { siteInfo } = command.netlify + if (!siteInfo.build_settings?.repo_url) { + return logAndThrowError( + 'This project is not connected to a git repository. Commits are only available for git-backed projects.', + ) + } + const api = createAgentsApi(command.netlify) + + let targetBranch = options.branch?.trim() + + if (!targetBranch) { + const lookupSpinner = startSpinner({ text: 'Looking up agent task...' }) + let runner: AgentRunner + try { + runner = await api.getAgentRunner(id) + stopSpinner({ spinner: lookupSpinner }) + } catch (error_) { + stopSpinner({ spinner: lookupSpinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to fetch agent task: ${error.message}`) + } + + const suggestion = pickDefaultBranch(runner) + if (!suggestion) { + return logAndThrowError('Could not determine a target branch. Pass --branch .') + } + + if (options.json || !process.stdin.isTTY) { + targetBranch = suggestion.branch + } else { + log(chalk.dim(`Default: ${suggestion.branch} (${suggestion.reason})`)) + const { branchInput } = await inquirer.prompt<{ branchInput: string }>([ + { + type: 'input', + name: 'branchInput', + message: 'Which branch should the agent commit to?', + default: suggestion.branch, + validate: (input: string) => (input.trim().length > 0 ? true : 'Branch name is required'), + }, + ]) + targetBranch = branchInput.trim() + } + } + + const 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..f2e0d05139d 100644 --- a/src/commands/agents/agents-create.ts +++ b/src/commands/agents/agents-create.ts @@ -1,134 +1,143 @@ +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 } from './constants.js' +import { uploadAttachments, type UploadedAttachment } from './attachments.js' +import type { CreateAgentRunnerPayload } from './types.js' +import { + buildAgentDashboardUrl, + checkModelAvailability, + formatBytes, + formatStatus, + getAgentName, + sanitizePromptText, + validateAgent, + validatePrompt, +} from './utils.js' interface AgentCreateOptions extends OptionValues { prompt?: string agent?: string branch?: string model?: string + fromDeploy?: string + parent?: 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', + }) + if (result.status === 0) { + 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 + 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: sanitizePromptText(finalPrompt), + agent, + model: options.model, + branch, + deploy_id: options.fromDeploy, + parent_agent_runner_id: options.parent, + 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 +146,122 @@ 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.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(buildAgentDashboardUrl(siteInfo.name, 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..4e2d62506d1 --- /dev/null +++ b/src/commands/agents/agents-follow-up.ts @@ -0,0 +1,155 @@ +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 { AVAILABLE_AGENTS, TERMINAL_SESSION_STATES, type AvailableAgent } from './constants.js' +import type { CreateAgentRunnerSessionPayload } from './types.js' +import { + checkModelAvailability, + formatBytes, + formatStatus, + getAgentName, + sanitizePromptText, + validateAgent, + validatePrompt, +} from './utils.js' + +interface AgentFollowUpOptions extends OptionValues { + prompt?: string + agent?: string + model?: 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) { + if (options.json) { + return logAndThrowError('A prompt is required. Pass it as the positional argument or via --prompt.') + } + const { promptInput } = await inquirer.prompt<{ promptInput: string }>([ + { + type: 'input', + 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 lastSessionAgent: AvailableAgent | undefined + let lastSessionModel: string | undefined + let recent: import('./types.js').AgentRunnerSession | undefined + try { + const recentSessions = await api.listAgentRunnerSessions(id, { page: 1, per_page: 1, order_by: 'desc' }) + if (recentSessions.length > 0) recent = recentSessions[0] + } catch { + // If lookup fails, fall through and let the create call surface the real error. + } + + if (recent && !TERMINAL_SESSION_STATES.includes(recent.state as (typeof TERMINAL_SESSION_STATES)[number])) { + log(chalk.yellow('A session is already running on this task. Wait for it to finish or stop it first:')) + log(` ${chalk.cyan(`netlify agents:stop ${id}`)}`) + return logAndThrowError('Cannot create a follow-up while a session is still active') + } + + if (recent) { + const previousAgent = recent.agent_config?.agent + if (previousAgent && AVAILABLE_AGENTS.some((entry) => entry.value === previousAgent)) { + lastSessionAgent = previousAgent + } + if (recent.agent_config?.model) lastSessionModel = recent.agent_config.model + } + + let agent: AvailableAgent | undefined = lastSessionAgent + if (options.agent) { + const valid = validateAgent(options.agent) + if (valid !== true) return logAndThrowError(valid) + agent = options.agent as AvailableAgent + } + const model = options.model ?? lastSessionModel + if (model && agent) { + const valid = await checkModelAvailability(api, agent, 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: sanitizePromptText(finalPrompt), + agent, + model, + 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))}${model ? ` (${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 & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + if (error.message.toLowerCase().includes('active session')) { + log() + log(chalk.yellow('A session is already running on this task. Wait for it to finish or stop it first:')) + 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..a14a83de550 100644 --- a/src/commands/agents/agents-list.ts +++ b/src/commands/agents/agents-list.ts @@ -1,162 +1,199 @@ 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, isListStatusFilter, truncateText, validateListStatusFilter } 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 ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/ + +const toUnixSeconds = (input?: string): number | undefined => { + if (!input) return undefined + if (!ISO_DATE_PATTERN.test(input)) { + throw new Error(`Invalid date "${input}". Use an ISO timestamp like 2026-05-01T00:00:00Z.`) + } + const parsed = Date.parse(input) + if (Number.isNaN(parsed)) { + throw new Error(`Invalid date "${input}". Use an ISO timestamp like 2026-05-01T00:00:00Z.`) + } + 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 MAX_PER_PAGE = 100 + +const buildFilters = (options: AgentListOptions): ListAgentRunnersFilters => { + const filters: ListAgentRunnersFilters = {} + if (options.status) { + const valid = validateListStatusFilter(options.status) + if (valid !== true) throw new Error(valid) + if (isListStatusFilter(options.status)) filters.state = options.status + } + if (options.branch) filters.branch = options.branch + if (options.user) filters.user_id = options.user + if (options.title) filters.title = options.title + filters.from = toUnixSeconds(options.since) + filters.to = toUnixSeconds(options.until) + filters.page = parsePositiveInt(options.page, 'page') + const perPage = parsePositiveInt(options.perPage, 'per-page') + if (perPage !== undefined && perPage > MAX_PER_PAGE) { + throw new Error( + `--per-page must be ${MAX_PER_PAGE.toString()} or fewer (the server caps at ${MAX_PER_PAGE.toString()})`, + ) + } + filters.per_page = perPage + 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) + if (options.account) { + const droppedFilters = ['branch', 'since', 'until'].filter((key) => options[key as keyof AgentListOptions]) + if (droppedFilters.length > 0) { + log(chalk.yellow(`⚠ --${droppedFilters.join(', --')} are ignored when --account is set.`)) } + } - const 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, - }, - }, - ) - - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { error?: string } - throw new Error(errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`) - } + const spinner = startSpinner({ text: 'Fetching agent tasks...' }) - const agentRunners = (await response.json()) as AgentRunner[] | null | undefined - stopSpinner({ spinner: listSpinner }) + try { + const result = options.account + ? await api.listAgentRunnersForAccount(options.account, filters) + : await api.listAgentRunners(site.id ?? '', filters) + stopSpinner({ spinner }) if (options.json) { - logJson(agentRunners) - return agentRunners + logJson(result.data) + return result.data } - if (!agentRunners || agentRunners.length === 0) { - log(chalk.yellow('No agent tasks found for this site.')) - log(``) - log(`Create your first agent task with:`) - log(` ${chalk.cyan('netlify agents:create')}`) - return + if (options.ndjson) { + for (const runner of result.data) { + process.stdout.write(`${JSON.stringify(runner)}\n`) + } + return result.data } - // Fetch agent info for each runner - const agentInfo = new Map() - const agentSpinner = startSpinner({ text: 'Loading agent information...' }) - - try { - // Fetch latest session for each runner in parallel to get agent info - const sessionPromises = agentRunners.map(async (runner) => { - try { - const sessionsResponse = await fetch( - `${apiOpts.scheme ?? 'https'}://${apiOpts.host ?? api.host}/api/v1/agent_runners/${ - runner.id - }/sessions?page=1&per_page=1`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${api.accessToken ?? ''}`, - 'User-Agent': apiOpts.userAgent, - }, - }, - ) - - if (sessionsResponse.ok) { - const sessions = (await sessionsResponse.json()) as AgentRunnerSession[] | undefined - if (sessions && sessions.length > 0 && sessions[0].agent_config) { - const { agent } = sessions[0].agent_config - if (agent) { - agentInfo.set(runner.id, agent) - } - } - } - } catch { - // Failed to fetch session for this runner, continue without agent info - } - }) - - // Wait for all session fetches to complete - await Promise.allSettled(sessionPromises) - stopSpinner({ spinner: agentSpinner }) - } catch { - // If parallel fetch fails entirely, continue without agent info - stopSpinner({ spinner: agentSpinner, error: true }) + if (result.data.length === 0) { + const emptyScope = options.account ? `account ${options.account}` : 'this site' + log(chalk.yellow(`No agent tasks found for ${emptyScope}.`)) + log() + log(`Create your first agent task with:`) + log(` ${chalk.cyan('netlify agents:create')}`) + 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 baseColumnLabel = isGitBased ? 'BRANCH' : 'BASE' - table.setHeading('ID', 'STATUS', 'AGENT', 'PROMPT', baseColumnLabel, 'DURATION', 'CREATED') - - agentRunners.forEach((runner) => { - const baseValue = isGitBased ? truncateText(runner.branch ?? 'unknown', 12) : 'Production' - + const scope = options.account ? `account ${options.account}` : siteInfo.name + const table = new AsciiTable(`Agent Tasks for ${scope}`) + // Account-wide listing spans multiple sites, so we can't infer a single base column from the linked site. + const baseColumnLabel = options.account ? 'BRANCH/BASE' : isGitBased ? 'BRANCH' : 'BASE' + table.setHeading('ID', 'STATUS', 'PROMPT', baseColumnLabel, 'DURATION', 'CREATED') + + for (const runner of result.data) { + const baseValue = options.account + ? runner.branch + ? truncateText(runner.branch, 12) + : 'Production' + : 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(), + new Date(runner.created_at).toISOString().slice(0, 10), ) - }) - - // 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 + const error = error_ as Error & { status?: number } + stopSpinner({ spinner, error: true }) + if (options.account && error.status === 404) { + return logAndThrowError( + `Agent tasks are not available for account "${options.account}". Check that the slug is correct and that your account has access to agent tasks.`, + ) + } + return logAndThrowError(`Failed to list agent tasks: ${error.message}`) + } +} - 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..93c66770a98 --- /dev/null +++ b/src/commands/agents/agents-open.ts @@ -0,0 +1,81 @@ +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' +import { buildAgentDashboardUrl } from './utils.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 = buildAgentDashboardUrl(siteInfo.name, 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 & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to fetch agent task: ${error.message}`) + } + + if (target === 'pr') { + if (runner.pr_url) return openUrl(runner.pr_url) + if (runner.pr_is_being_created) { + log(chalk.yellow('A pull request is being created. Try again in a moment.')) + return + } + if (runner.pr_error) { + log(chalk.red(`Pull request creation failed: ${runner.pr_error}`)) + log(`Retry with: ${chalk.cyan(`netlify agents:pr ${id}`)}`) + return + } + log(chalk.yellow('No pull request exists for this agent task.')) + log(`Create one with: ${chalk.cyan(`netlify agents:pr ${id}`)}`) + return + } + + const previewUrl = runner.latest_session_deploy_url + 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..629d87eb9c3 --- /dev/null +++ b/src/commands/agents/agents-pr.ts @@ -0,0 +1,50 @@ +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 { siteInfo } = command.netlify + if (!siteInfo.build_settings?.repo_url) { + return logAndThrowError( + 'This project is not connected to a git repository. Pull requests are only available for git-backed projects.', + ) + } + const api = createAgentsApi(command.netlify) + + const spinner = startSpinner({ text: 'Creating pull request...' }) + 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 & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to create pull request: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-publish.ts b/src/commands/agents/agents-publish.ts new file mode 100644 index 00000000000..6dff5464bb6 --- /dev/null +++ b/src/commands/agents/agents-publish.ts @@ -0,0 +1,126 @@ +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' +import type { AgentRunner } from './types.js' +import { buildAgentDashboardUrl } from './utils.js' + +interface AgentPublishOptions extends OptionValues { + json?: boolean + yes?: boolean + force?: boolean +} + +const isOutOfSync = (runner: AgentRunner): boolean => + Boolean(runner.needs_git_sync || runner.rebase_available || runner.merge_target_available) + +const describeOutOfSync = (runner: AgentRunner): string => { + if (runner.needs_git_sync) return 'the code origin has changed since this run started' + if (runner.merge_target_available) return 'the target branch has new commits' + return 'production has moved on since this run started' +} + +export const agentsPublish = async (id: string, options: AgentPublishOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + await command.authenticate() + const { siteInfo } = command.netlify + const api = createAgentsApi(command.netlify) + + const lookupSpinner = startSpinner({ text: 'Checking agent task state...' }) + let runner: AgentRunner + try { + runner = await api.getAgentRunner(id) + stopSpinner({ spinner: lookupSpinner }) + } catch (error_) { + stopSpinner({ spinner: lookupSpinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to fetch agent task: ${error.message}`) + } + + const outOfSync = isOutOfSync(runner) + if (outOfSync && !options.force) { + if (options.json) { + return logAndThrowError( + `Refusing to publish: ${describeOutOfSync(runner)}. Run netlify agents:sync ${id} first, or pass --force.`, + ) + } + log(chalk.yellow(`! This agent task is out of date: ${describeOutOfSync(runner)}.`)) + log(` Sync first: ${chalk.cyan(`netlify agents:sync ${id}`)}`) + log(` Or override: pass ${chalk.cyan('--force')} to publish the existing diff as-is`) + if (!options.yes) { + if (!process.stdin.isTTY) return logAndThrowError('Refusing to publish out-of-date run without --force') + const { action } = await inquirer.prompt<{ action: 'sync' | 'publish' | 'cancel' }>([ + { + type: 'list', + name: 'action', + message: 'How would you like to proceed?', + choices: [ + { + name: 'Sync with production now, then re-run publish manually (recommended)', + value: 'sync', + }, + { name: 'Publish anyway (use the current diff as-is)', value: 'publish' }, + { name: 'Cancel', value: 'cancel' }, + ], + default: 'sync', + }, + ]) + if (action === 'cancel') return exit() + if (action === 'sync') { + const { agentsSync } = await import('./agents-sync.js') + await agentsSync(id, { yes: true }, command) + log() + log(chalk.dim(`After the sync completes, re-run: ${chalk.cyan(`netlify agents:publish ${id}`)}`)) + return + } + // action === 'publish' falls through to the publish call below + } else { + return logAndThrowError('Refusing to publish out-of-date run without --force') + } + } + + if (!options.yes && !options.json && !outOfSync) { + if (!process.stdin.isTTY) { + return logAndThrowError('Refusing to publish without --yes when stdin is not a TTY') + } + 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 updated = await api.agentRunnerPublishToProduction(id) + stopSpinner({ spinner }) + + if (options.json) { + logJson(updated) + return updated + } + + log(`${chalk.green('✓')} Published agent task to production!`) + log() + log(` Task ID: ${chalk.cyan(updated.id)}`) + if (updated.merge_commit_sha) log(` Commit: ${chalk.cyan(updated.merge_commit_sha)}`) + log(` Browser: ${chalk.blue(buildAgentDashboardUrl(siteInfo.name, updated.id))}`) + return updated + } 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..a2671fbebef --- /dev/null +++ b/src/commands/agents/agents-redeploy.ts @@ -0,0 +1,70 @@ +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, order_by: 'desc' }) + 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 & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task or session not found: ${id} / ${sessionId}`) + return logAndThrowError(`Failed to redeploy: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-rename.ts b/src/commands/agents/agents-rename.ts new file mode 100644 index 00000000000..f84531f0a85 --- /dev/null +++ b/src/commands/agents/agents-rename.ts @@ -0,0 +1,42 @@ +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 { sanitizeRunnerTitle, validateRunnerTitle } from './utils.js' + +interface AgentRenameOptions extends OptionValues { + json?: boolean +} + +export const agentsRename = async (id: string, title: string, options: AgentRenameOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + const valid = validateRunnerTitle(title) + if (valid !== true) return logAndThrowError(valid) + const sanitized = sanitizeRunnerTitle(title) + + await command.authenticate() + const api = createAgentsApi(command.netlify) + + const spinner = startSpinner({ text: 'Renaming agent task...' }) + try { + const runner = await api.updateAgentRunner(id, { title: sanitized }) + stopSpinner({ spinner }) + + if (options.json) { + logJson(runner) + return runner + } + + log(`${chalk.green('✓')} Agent task renamed.`) + log(` Task ID: ${chalk.cyan(runner.id)}`) + log(` Title: ${chalk.cyan(runner.title ?? sanitized)}`) + return runner + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to rename: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-revert.ts b/src/commands/agents/agents-revert.ts new file mode 100644 index 00000000000..40f426ad0ec --- /dev/null +++ b/src/commands/agents/agents-revert.ts @@ -0,0 +1,56 @@ +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.revertAgentRunner(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 & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task or session not found: ${id} / ${options.session}`) + return logAndThrowError(`Failed to revert: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-show.ts b/src/commands/agents/agents-show.ts index 859300a158f..c2bf0f2d151 100644 --- a/src/commands/agents/agents-show.ts +++ b/src/commands/agents/agents-show.ts @@ -1,172 +1,515 @@ 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 { + buildAgentDashboardUrl, + formatDate, + formatDuration, + formatPrState, + 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 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +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) + } + + 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 + } + setText(text: string): void { + this.currentText = text + this.draw() + } + + 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)}`) + } +} + +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 (!id) { - return logAndThrowError('Agent task ID is required') + if (options.session) { + return showSingleSession(api, id, options.session, options, command) + } + + if (options.watch) { + if (options.json) { + return logAndThrowError('--watch and --json cannot be combined') + } + return watchAgentTask(api, id, command) } - const showSpinner = startSpinner({ text: 'Fetching agent task details...' }) + return showAgentTask(api, id, options, command) +} +const showAgentTask = async (api: AgentsApi, id: string, options: AgentShowOptions, command: BaseCommand) => { + const spinner = startSpinner({ text: 'Fetching agent task details...' }) 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, - }, - }, - ) + const [runner, sessions] = await Promise.all([ + api.getAgentRunner(id), + api.listAgentRunnerSessions(id, { page: 1, per_page: 100, order_by: 'desc' }), + ]) + 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) { + const payload = { ...runner, sessions } + logJson(payload) + return payload } - const agentRunner = (await response.json()) as AgentRunner - stopSpinner({ spinner: showSpinner }) + 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}`) + } +} + +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(agentRunner) - return agentRunner - } - - // Display detailed information - log(chalk.bold('Agent Task Details')) - log(``) - - 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 ?? ''})`) - - if (agentRunner.user) { - log(` Created by: ${agentRunner.user.full_name ?? 'Anonymous'}`) - } - - // 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, - }, - }, - ) + logJson(session) + return session + } - if (sessionsResponse.ok) { - sessions = (await sessionsResponse.json()) as AgentRunnerSession[] | undefined - } - } catch { - // Sessions fetch failed, but continue without session data + 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}`) + } +} - log(``) - log(chalk.bold('Configuration:')) +const renderAgentTask = (runner: AgentRunner, sessions: AgentRunnerSession[], command: BaseCommand) => { + const { siteInfo, site } = command.netlify - // 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 + log(chalk.bold('Agent Task Details')) + log() - if (agent) { - log(` Agent: ${chalk.cyan(getAgentName(agent))}`) - } - if (model) { - log(` Model: ${chalk.cyan(model)}`) - } - } + 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.sha) log(` Start commit: ${chalk.cyan(runner.sha.slice(0, 7))}`) + if (runner.user) log(` Created by: ${runner.user.full_name ?? 'Anonymous'}`) + if (runner.contributors && runner.contributors.length > 1) { + log(` Contributors: ${runner.contributors.map((entry) => entry.full_name ?? 'Anonymous').join(', ')}`) + } + + 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(` Title: ${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) } + } - const isGitBased = Boolean(siteInfo.build_settings?.repo_branch) + if (runner.pr_url || runner.pr_error || runner.pr_is_being_created) { + log() + log(chalk.bold('Pull Request:')) + if (runner.pr_url) log(` URL: ${chalk.blue(runner.pr_url)}`) + if (runner.pr_state) log(` State: ${chalk.cyan(formatPrState(runner.pr_state))}`) + if (runner.pr_is_being_created) log(` ${chalk.yellow('Status:')} being created`) + if (runner.pr_error) log(` ${chalk.red('Error:')} ${runner.pr_error}`) + } - 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')}`) + 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 (runner.needs_git_sync || runner.rebase_available || runner.merge_target_available) { + log() + log(chalk.bold('Sync needed:')) + if (runner.needs_git_sync) { + log(` ${chalk.yellow('!')} Code origin changed since this run started.`) + } else if (runner.merge_target_available) { + log(` ${chalk.yellow('!')} The target branch has new commits.`) + } else if (runner.rebase_available) { + log(` ${chalk.yellow('!')} Production has moved on since this run started.`) } + log(` Run: ${chalk.cyan(`netlify agents:sync ${runner.id}`)}`) + } + + log() + log(chalk.bold('Actions:')) + if (runner.state === 'running' || runner.state === 'new') { + 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(buildAgentDashboardUrl(siteInfo.name, runner.id))}`) +} - log(``) - log(chalk.bold('Task:')) - log(` Prompt: ${chalk.dim(agentRunner.title ?? 'No title')}`) +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.is_published) meta.push(chalk.green('PUBLISHED')) + if (session.is_discarded) meta.push(chalk.gray('DISCARDED')) + if (session.done_at) { + meta.push(chalk.dim(`took ${formatDuration(session.created_at, session.done_at)}`)) + } else if (session.state === 'running') { + meta.push(chalk.dim(`running for ${formatDuration(session.created_at)}`)) + } + log(` ${meta.join(' • ')}`) + log(` ${chalk.dim('id:')} ${session.id}`) + if (session.user) log(` ${chalk.dim('by:')} ${session.user.full_name ?? 'Anonymous'}`) + if (session.state === 'running' && session.current_task) { + log(` ${chalk.dim('current:')} ${chalk.yellow(session.current_task)}`) + } + if (session.deploy_url) log(` ${chalk.dim('preview:')} ${chalk.blue(session.deploy_url)}`) + if (session.commit_sha) log(` ${chalk.dim('commit:')} ${chalk.cyan(session.commit_sha)}`) + if (session.attached_file_keys && session.attached_file_keys.length > 0) { + log(` ${chalk.dim('attachments:')} ${session.attached_file_keys.length.toString()} file(s)`) + } - if (agentRunner.current_task) { - log(` Current Task: ${chalk.yellow(agentRunner.current_task)}`) + 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.is_published) log(` ${chalk.green('Published to production')}`) + if (session.is_discarded) log(` ${chalk.gray('Discarded (a later revert reset to an earlier session)')}`) + if (session.mode) log(` Mode: ${chalk.cyan(session.mode)}`) + if (session.user) log(` Author: ${chalk.cyan(session.user.full_name ?? 'Anonymous')}`) + if (session.agent_config?.agent) log(` Agent: ${chalk.cyan(getAgentName(session.agent_config.agent))}`) + if (session.agent_config?.model) log(` Model: ${chalk.cyan(session.agent_config.model)}`) + if (session.state === 'running' && session.current_task) { + log(` Current Task: ${chalk.yellow(session.current_task)}`) + } + + log() + log(chalk.bold('Prompt:')) + 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)}`) } + } - // 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 (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(buildAgentDashboardUrl(siteInfo.name, runnerId))}`) +} + +interface WatchSnapshot { + state?: string + currentTask?: string + sessionStates: Map + sessionIds: string[] + sessionStepCounts: Map +} + +const takeSnapshot = (runner: AgentRunner, sessions: AgentRunnerSession[]): WatchSnapshot => { + const sessionStates = new Map() + const sessionStepCounts = new Map() + for (const session of sessions) { + sessionStates.set(session.id, session.state) + sessionStepCounts.set(session.id, session.steps?.length ?? 0) + } + return { + state: runner.state, + currentTask: runner.current_task, + sessionStates, + sessionIds: sessions.map((session) => session.id), + sessionStepCounts, + } +} + +const watchAgentTask = async (api: AgentsApi, id: string, command: BaseCommand) => { + const renderer = new WatchRenderer() + let previous: WatchSnapshot | null = null + let lastRunner: AgentRunner + let lastSessions: AgentRunnerSession[] + try { + ;[lastRunner, lastSessions] = await Promise.all([ + api.getAgentRunner(id), + api.listAgentRunnerSessions(id, { page: 1, per_page: 100, order_by: 'desc' }), + ]) + } catch (error_) { + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to watch agent task: ${error.message}`) + } + + log(`${chalk.cyan('Watching')} agent task ${chalk.bold(id)} ${chalk.dim('(Ctrl+C to stop)')}`) + log() + + const MAX_CONSECUTIVE_FAILURES = 10 + let consecutiveFailures = 0 + renderer.start() + try { + for (;;) { + const events = computeWatchEvents(lastRunner, lastSessions, previous) + for (const event of events) renderer.print(event) - if (sessions.length > 3) { - log(` ${chalk.dim(`... and ${(sessions.length - 3).toString()} more runs`)}`) + renderer.setText(describeBottomLine(lastRunner, lastSessions)) + previous = takeSnapshot(lastRunner, lastSessions) + + if (TERMINAL_AGENT_STATES.includes(lastRunner.state as (typeof TERMINAL_AGENT_STATES)[number])) { + break + } + await sleep(POLL_INTERVAL_MS) + try { + ;[lastRunner, lastSessions] = await Promise.all([ + api.getAgentRunner(id), + api.listAgentRunnerSessions(id, { page: 1, per_page: 100, order_by: 'desc' }), + ]) + consecutiveFailures = 0 + } catch (error_) { + const error = error_ as Error & { status?: number } + if (error.status === 404) { + renderer.stop() + return logAndThrowError(`Agent task ${id} is no longer accessible (archived or deleted).`) + } + consecutiveFailures += 1 + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + renderer.stop() + return logAndThrowError( + `Watch aborted after ${MAX_CONSECUTIVE_FAILURES.toString()} consecutive polling failures: ${error.message}`, + ) + } + renderer.print( + `${chalk.yellow('!')} ${chalk.dim( + `poll failed (${consecutiveFailures.toString()}/${MAX_CONSECUTIVE_FAILURES.toString()}): ${ + error.message + }, retrying`, + )}`, + ) } } + } finally { + renderer.stop() + } - log(``) - log(chalk.bold('Actions:')) + log() + renderAgentTask(lastRunner, lastSessions, command) + return lastRunner +} - if (agentRunner.state === 'running' || agentRunner.state === 'new') { - log(` Stop: ${chalk.cyan(`netlify agents:stop ${agentRunner.id}`)}`) +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'}` +} + +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..df4350ec1af 100644 --- a/src/commands/agents/agents-stop.ts +++ b/src/commands/agents/agents-stop.ts @@ -1,107 +1,138 @@ 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 (!id) { - return logAndThrowError('Agent task ID is required') + if (options.session) { + return stopSession(api, id, options.session, options) } - const statusSpinner = startSpinner({ text: 'Checking agent task status...' }) + 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 { - // 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.')) + runner = await api.getAgentRunner(id) + stopSpinner({ spinner: fetchSpinner }) + } catch (error_) { + stopSpinner({ spinner: fetchSpinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to fetch agent task: ${error.message}`) + } + 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 + } + + 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 { + await api.deleteAgentRunner(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 & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task or session not found: ${id} / ${sessionId}`) + return logAndThrowError(`Failed to fetch session: ${error.message}`) + } + + if (TERMINAL_SESSION_STATES.includes(session.state as (typeof TERMINAL_SESSION_STATES)[number])) { + log(chalk.yellow(`Session is already ${session.state}.`)) + return session + } + + 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.deleteAgentRunnerSession(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 + } - stopSpinner({ spinner: statusSpinner, error: true }) + log(`${chalk.green('✓')} Session stopped successfully!`) + log() + log(` Session ID: ${chalk.cyan(sessionId)}`) + log(` Previous Status: ${formatStatus(session.state)}`) + return result +} - return logAndThrowError(`Failed to stop agent task: ${error.message}`) +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-sync.ts b/src/commands/agents/agents-sync.ts new file mode 100644 index 00000000000..62b1445becc --- /dev/null +++ b/src/commands/agents/agents-sync.ts @@ -0,0 +1,105 @@ +import type { OptionValues } from 'commander' +import inquirer from 'inquirer' + +import { chalk, exit, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi, type AgentsApi } from './api.js' +import type { AgentRunner } from './types.js' + +interface AgentSyncOptions extends OptionValues { + json?: boolean + yes?: boolean +} + +type SyncStrategy = 'sync_git_origin' | 'merge_target' | 'rebase' + +const pickStrategy = (runner: AgentRunner): SyncStrategy | null => { + if (runner.needs_git_sync) return 'sync_git_origin' + if (runner.merge_target_available) return 'merge_target' + if (runner.rebase_available) return 'rebase' + return null +} + +const describeStrategy = (strategy: SyncStrategy, runner: AgentRunner): string => { + const target = runner.branch ? ` (target: ${runner.branch})` : '' + switch (strategy) { + case 'sync_git_origin': + return `sync with the remote git origin${target}` + case 'merge_target': + return `merge the latest target branch into this agent run${target}` + case 'rebase': + return 'reapply changes on top of the latest production deploy' + } +} + +const runStrategy = (api: AgentsApi, strategy: SyncStrategy, id: string): Promise => { + switch (strategy) { + case 'sync_git_origin': + return api.syncGitOriginAgentRunner(id) + case 'merge_target': + return api.mergeTargetAgentRunner(id) + case 'rebase': + return api.rebaseAgentRunner(id) + } +} + +export const agentsSync = async (id: string, options: AgentSyncOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent task ID is required') + await command.authenticate() + const api = createAgentsApi(command.netlify) + + const lookupSpinner = startSpinner({ text: 'Checking agent task state...' }) + let runner: AgentRunner + try { + runner = await api.getAgentRunner(id) + stopSpinner({ spinner: lookupSpinner }) + } catch (error_) { + stopSpinner({ spinner: lookupSpinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent task not found: ${id}`) + return logAndThrowError(`Failed to fetch agent task: ${error.message}`) + } + + const strategy = pickStrategy(runner) + if (!strategy) { + log(chalk.yellow('Nothing to sync — this agent task is already up to date.')) + return runner + } + + if (!options.yes && !options.json) { + if (!process.stdin.isTTY) { + return logAndThrowError('Refusing to sync without --yes when stdin is not a TTY') + } + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message: `Sync agent task ${id}? This will ${describeStrategy(strategy, runner)}.`, + default: false, + }, + ]) + if (!confirmed) return exit() + } + + const spinner = startSpinner({ text: 'Syncing agent task...' }) + try { + const updated = await runStrategy(api, strategy, id) + stopSpinner({ spinner }) + + if (options.json) { + logJson(updated) + return updated + } + + log(`${chalk.green('✓')} Sync started: ${describeStrategy(strategy, runner)}.`) + log(` Task ID: ${chalk.cyan(updated.id)}`) + log() + log(`Watch progress: ${chalk.cyan(`netlify agents:show ${updated.id} --watch`)}`) + return updated + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to sync: ${error.message}`) + } +} diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index b139a133119..abce1e269ec 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,9 @@ 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('--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 +31,58 @@ 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', ]) .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('--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 (running, done, error, archived)') + .option('-b, --branch ', 'filter by branch') + .option('-u, --user ', 'filter by user ID') + .option('-t, --title ', 'filter by title (case-insensitive contains)') + .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 running', + 'netlify agents:list --status archived', + 'netlify agents:list --branch main --since 2026-04-01', + 'netlify agents:list --account my-team', + 'netlify agents:list --ndjson', + ]) .action(async (options: OptionValues, command: BaseCommand) => { const { agentsList } = await import('./agents-list.js') await agentsList(options, command) @@ -50,12 +92,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 +110,184 @@ 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 (binary is stripped 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('--force', 'publish even when the run is out of sync with production') + .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', + 'netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --force', + ]) + .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) + }) + + program + .command('agents:rename') + .argument('', 'agent task ID') + .argument('', 'new title for the agent task') + .description('Rename an agent task') + .option('--json', 'output result as JSON') + .option('--project <project>', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples(['netlify agents:rename 60c7c3b3e7b4a0001f5e4b3a "Add dark mode toggle"']) + .action(async (id: string, title: string, options: OptionValues, command: BaseCommand) => { + const { agentsRename } = await import('./agents-rename.js') + await agentsRename(id, title, options, command) + }) + + program + .command('agents:sync') + .argument('<id>', 'agent task ID') + .description('Sync an agent task with the latest production code or remote git origin') + .option('-y, --yes', 'skip confirmation prompt') + .option('--json', 'output result as JSON') + .option('--project <project>', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples(['netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a', 'netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a --yes']) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsSync } = await import('./agents-sync.js') + await agentsSync(id, options, command) + }) + const name = chalk.greenBright('`agents`') return program @@ -88,7 +301,10 @@ 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: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..01594d9ebc3 --- /dev/null +++ b/src/commands/agents/api.ts @@ -0,0 +1,259 @@ +// TODO: Migrate to @netlify/api once these endpoints are public. +// They are marked x-internal in bitballoon (#21736). +// Method names mirror the bitballoon @operation_id to keep the future swap quick. + +import type BaseCommand from '../base-command.js' +import { parseLinkHeader } from './utils.js' +import type { + 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<T> = (response: Response) => Promise<T> + +type SearchParamValue = string | number | boolean | null | undefined + +const buildSearchParams = (entries: Record<string, SearchParamValue>): 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<string, string> = {}): Record<string, string> => ({ + Authorization: `Bearer ${api.accessToken ?? ''}`, + 'User-Agent': apiOpts.userAgent, + ...extra, + }) + + const throwForStatus = async (response: Response): Promise<never> => { + 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 <T>(path: string, init: RequestInit, handler: RawResponseHandler<T>): Promise<T> => { + const response = await fetch(`${baseUrl}${path}`, init) + if (!response.ok) await throwForStatus(response) + return handler(response) + } + + const requestJson = async <T>(path: string, init: RequestInit = {}): Promise<T> => + 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<void> => + 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<PaginatedResult<AgentRunner[]>> => { + 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<PaginatedResult<AgentRunner[]>> => { + 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<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}`, getInit()) + + const createAgentRunner = (siteId: string, payload: CreateAgentRunnerPayload): Promise<AgentRunner> => { + const params = buildSearchParams({ site_id: siteId }) + return requestJson<AgentRunner>(`/agent_runners?${params.toString()}`, jsonInit('POST', payload)) + } + + const deleteAgentRunner = (id: string): Promise<void> => + requestNoContent(`/agent_runners/${id}`, { method: 'DELETE', headers: baseHeaders() }) + + const archiveAgentRunner = (id: string): Promise<void> => + requestNoContent(`/agent_runners/${id}/archive`, { method: 'POST', headers: baseHeaders() }) + + const listAgentRunnerSessions = async ( + id: string, + filters: ListAgentRunnerSessionsFilters = {}, + ): Promise<AgentRunnerSession[]> => { + const page = filters.page ?? 1 + const perPage = filters.per_page ?? DEFAULT_PER_PAGE + const params = buildSearchParams({ ...filters, page, per_page: perPage }) + return requestJson<AgentRunnerSession[]>(`/agent_runners/${id}/sessions?${params.toString()}`, getInit()) + } + + const getAgentRunnerSession = (id: string, sessionId: string): Promise<AgentRunnerSession> => + requestJson<AgentRunnerSession>(`/agent_runners/${id}/sessions/${sessionId}`, getInit()) + + const createAgentRunnerSession = ( + id: string, + payload: CreateAgentRunnerSessionPayload, + ): Promise<AgentRunnerSession> => + requestJson<AgentRunnerSession>(`/agent_runners/${id}/sessions`, jsonInit('POST', payload)) + + const deleteAgentRunnerSession = (id: string, sessionId: string): Promise<void> => + requestNoContent(`/agent_runners/${id}/sessions/${sessionId}`, { + method: 'DELETE', + headers: baseHeaders(), + }) + + const redeployAgentRunnerSession = (id: string, sessionId: string): Promise<AgentRunnerSession> => + requestJson<AgentRunnerSession>(`/agent_runners/${id}/sessions/${sessionId}/redeploy`, jsonInit('POST')) + + const getAgentRunnerDiff = async (id: string, params: DiffParams = {}): Promise<PaginatedResult<string>> => { + 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<string> => { + 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<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}/pull_request`, jsonInit('POST')) + + const agentRunnerCommitToBranch = (id: string, targetBranch: string): Promise<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}/commit`, jsonInit('POST', { target_branch: targetBranch })) + + const agentRunnerPublishToProduction = (id: string): Promise<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}/publish_to_production`, jsonInit('POST')) + + const revertAgentRunner = (id: string, sessionId: string): Promise<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}/revert`, jsonInit('POST', { session_id: sessionId })) + + const updateAgentRunner = (id: string, payload: { title?: string; base_deploy_id?: string }): Promise<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}`, jsonInit('PATCH', payload)) + + const rebaseAgentRunner = (id: string): Promise<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}/rebase`, jsonInit('POST')) + + const mergeTargetAgentRunner = (id: string): Promise<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}/merge_target`, jsonInit('POST')) + + const syncGitOriginAgentRunner = (id: string): Promise<AgentRunner> => + requestJson<AgentRunner>(`/agent_runners/${id}/sync_git_origin`, jsonInit('POST')) + + const createAgentRunnerUploadUrl = (payload: { + account_id: string + filename: string + content_type: string + }): Promise<UploadUrlResponse> => + requestJson<UploadUrlResponse>(`/agent_runners/upload_url`, jsonInit('POST', payload)) + + const createAgentRunnerDeleteUrl = (payload: { account_id: string; file_key: string }): Promise<DeleteUrlResponse> => + requestJson<DeleteUrlResponse>(`/agent_runners/delete_url`, jsonInit('POST', payload)) + + let providersCache: AiGatewayProvidersResponse | null = null + const listAiGatewayProviders = async (): Promise<AiGatewayProvidersResponse> => { + 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, + updateAgentRunner, + deleteAgentRunner, + archiveAgentRunner, + listAgentRunnerSessions, + getAgentRunnerSession, + createAgentRunnerSession, + deleteAgentRunnerSession, + redeployAgentRunnerSession, + getAgentRunnerDiff, + getSessionResultDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'result'), + getSessionCumulativeDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'cumulative'), + agentRunnerPullRequest, + agentRunnerCommitToBranch, + agentRunnerPublishToProduction, + revertAgentRunner, + rebaseAgentRunner, + mergeTargetAgentRunner, + syncGitOriginAgentRunner, + createAgentRunnerUploadUrl, + createAgentRunnerDeleteUrl, + listAiGatewayProviders, + } +} + +export type AgentsApi = ReturnType<typeof createAgentsApi> diff --git a/src/commands/agents/attachments.ts b/src/commands/agents/attachments.ts new file mode 100644 index 00000000000..e9dc3a832e1 --- /dev/null +++ b/src/commands/agents/attachments.ts @@ -0,0 +1,112 @@ +import fs from 'fs/promises' +import path from 'path' + +import type { AgentsApi } from './api.js' +import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS_PER_REQUEST } from './constants.js' +import { formatBytes, getMimeType } from './utils.js' + +export interface UploadedAttachment { + path: string + filename: string + fileKey: string + size: number + contentType: string +} + +const cleanupOrphans = async (api: AgentsApi, accountId: string, fileKeys: string[]): Promise<void> => { + await Promise.allSettled( + fileKeys.map(async (fileKey) => { + try { + const { delete_url: deleteUrl } = await api.createAgentRunnerDeleteUrl({ + account_id: accountId, + file_key: fileKey, + }) + await fetch(deleteUrl, { method: 'DELETE' }) + } catch { + // Best-effort cleanup; if it fails, the orphan is the user's tenant problem. + } + }), + ) +} + +export const uploadAttachments = async ( + api: AgentsApi, + accountId: string, + filePaths: string[], +): Promise<UploadedAttachment[]> => { + if (filePaths.length === 0) return [] + if (filePaths.length > MAX_ATTACHMENTS_PER_REQUEST) { + throw new Error( + `Too many attachments: ${filePaths.length.toString()} given, max is ${MAX_ATTACHMENTS_PER_REQUEST.toString()}`, + ) + } + + const resolved = await Promise.all( + filePaths.map(async (filePath) => { + 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[] = [] + try { + for (const file of resolved) { + const { upload_url: uploadUrl, file_key: fileKey } = await api.createAgentRunnerUploadUrl({ + 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 + } catch (error) { + if (uploaded.length > 0) { + await cleanupOrphans( + api, + accountId, + uploaded.map((entry) => entry.fileKey), + ) + } + throw error + } +} diff --git a/src/commands/agents/constants.ts b/src/commands/agents/constants.ts index 03b5f94b287..0c608afedfc 100644 --- a/src/commands/agents/constants.ts +++ b/src/commands/agents/constants.ts @@ -1,27 +1,32 @@ 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 LIST_STATUS_FILTERS = ['running', 'done', 'error', 'archived'] as const + export const STATUS_COLORS = { new: chalk.blue, running: chalk.yellow, @@ -31,9 +36,14 @@ 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 const MAX_ATTACHMENTS_PER_REQUEST = 20 + export type AgentState = (typeof AGENT_STATES)[number] export type SessionState = (typeof SESSION_STATES)[number] +export type SessionMode = (typeof SESSION_MODES)[number] +export type ListStatusFilter = (typeof LIST_STATUS_FILTERS)[number] export type AvailableAgent = (typeof AVAILABLE_AGENTS)[number]['value'] diff --git a/src/commands/agents/types.ts b/src/commands/agents/types.ts index 345b87101f3..93a895c3882 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, ListStatusFilter, 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,19 +83,94 @@ export interface AgentRunnerSession { agent_runner_id: string dev_server_id?: string state: SessionState + mode?: SessionMode created_at: string updated_at: string done_at?: string title?: string + current_task?: string prompt: string 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<string, unknown> +} + +export interface CreateAgentRunnerPayload { + prompt: string + agent: AvailableAgent + model?: string + branch?: string + deploy_id?: string + parent_agent_runner_id?: string + file_keys?: string[] +} + +export interface CreateAgentRunnerSessionPayload { + prompt: string + agent?: AvailableAgent + model?: string + file_keys?: string[] +} + +export interface ListAgentRunnersFilters { + state?: ListStatusFilter + 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<T> { + 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 +178,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<Record<string, AiGatewayProviderInfo>> +} diff --git a/src/commands/agents/utils.ts b/src/commands/agents/utils.ts index 308fb6fb717..5762c40ef6b 100644 --- a/src/commands/agents/utils.ts +++ b/src/commands/agents/utils.ts @@ -1,5 +1,11 @@ -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, LIST_STATUS_FILTERS, STATUS_COLORS } from './constants.js' +import type { ListStatusFilter } from './constants.js' +import type { AgentsApi } from './api.js' +import type { AvailableAgent } from './constants.js' +import type { AgentRunnerSessionUsage } from './types.js' export const truncateText = (text: string, maxLength: number): string => { if (text.length <= maxLength) return text @@ -7,8 +13,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 +39,16 @@ export const formatStatus = (status: string): string => { return colorFn(status.toUpperCase()) } -export const validatePrompt = (input: string): boolean | string => { +const PR_STATE_LABELS: Record<string, string> = { + open: 'Open', + draft: 'Draft', + closed: 'Closed', + merged: 'Merged', +} + +export const formatPrState = (state: string): string => PR_STATE_LABELS[state.toLowerCase()] ?? state + +export const validatePrompt = (input: string): true | string => { if (!input || input.trim().length === 0) { return 'Please provide a prompt for the agent' } @@ -44,15 +58,156 @@ 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 TITLE_MAX_LENGTH = 200 + +const UNICODE_TAG_PATTERN = /[\u{E0000}-\u{E007F}]/gu +const CONTROL_CHAR_PATTERN = /\p{Cc}/gu + +export const sanitizePromptText = (text: string): string => text.replace(UNICODE_TAG_PATTERN, '') + +export const sanitizeRunnerTitle = (title: string): string => + sanitizePromptText(title).replace(CONTROL_CHAR_PATTERN, '').trim() + +export const validateRunnerTitle = (title: string): true | string => { + const sanitized = sanitizeRunnerTitle(title) + if (!sanitized) return 'A non-empty title is required' + if (sanitized.length > TITLE_MAX_LENGTH) return `Title must be ${TITLE_MAX_LENGTH.toString()} characters or fewer` + return true +} + +export const validateAgent = (agent: string): true | string => { + const validAgents = AVAILABLE_AGENTS.map((entry) => entry.value) as string[] if (!validAgents.includes(agent)) { return `Invalid agent. Available agents: ${validAgents.join(', ')}` } return true } +export const validateListStatusFilter = (status: string): true | string => { + if ((LIST_STATUS_FILTERS as readonly string[]).includes(status)) return true + return `--status accepts only ${LIST_STATUS_FILTERS.map((entry) => `"${entry}"`).join(', ')}` +} + +export const isListStatusFilter = (status: string): status is ListStatusFilter => + (LIST_STATUS_FILTERS as readonly string[]).includes(status) + +export const checkModelAvailability = async ( + api: AgentsApi, + agent: AvailableAgent, + model: string, +): Promise<true | string> => { + 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 } + +const NETLIFY_WEB_UI = (process.env.NETLIFY_WEB_UI ?? 'https://app.netlify.com').replace(/\/+$/, '') + +export const buildAgentDashboardUrl = (siteName: string, agentId: string): string => + `${NETLIFY_WEB_UI}/projects/${siteName}/agent-runs/${agentId}` + +export const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes.toString()} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + 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<string, string> = { + '.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<string, string> => { + if (!linkHeader) return {} + const result: Record<string, string> = {} + 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..f47ca19a79f --- /dev/null +++ b/tests/integration/commands/agents/agents-archive.test.ts @@ -0,0 +1,157 @@ +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 when the task is missing', 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('Agent task not found: test_id') + }) + }) + }) + + test('should surface other archive failures generically', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/archive', + method: 'POST' as const, + status: 500, + response: { error: 'something exploded' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:archive', 'test_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Failed to archive: something exploded') + }) + }) + }) + + 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..0119bea9858 --- /dev/null +++ b/tests/integration/commands/agents/agents-commit.test.ts @@ -0,0 +1,198 @@ +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 default to runner.branch when --branch is omitted', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: { ...mockAgentRunner, branch: 'feature-x' }, + }, + { + path: 'agent_runners/test_id/commit', + method: 'POST' as const, + response: { ...mockAgentRunner, merge_commit_sha: 'abc' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli(['agents:commit', 'test_id', '--json'], getCLIOptions({ apiUrl, builder })) + const commitRequest = requests.find((r) => r.path.endsWith('/commit') && r.method === 'POST') + expect(commitRequest?.body).toEqual({ target_branch: 'feature-x' }) + }) + }) + }) + + test('should default to runner.pr_branch when an open PR exists', async (t) => { + const runnerWithPr = { + ...mockAgentRunner, + branch: 'feature-x', + pr_branch: 'pr-branch', + pr_url: 'https://github.com/x/y/pull/1', + pr_state: 'open', + } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerWithPr, + }, + { + path: 'agent_runners/test_id/commit', + method: 'POST' as const, + response: { ...runnerWithPr, merge_commit_sha: 'abc' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli(['agents:commit', 'test_id', '--json'], getCLIOptions({ apiUrl, builder })) + const commitRequest = requests.find((r) => r.path.endsWith('/commit') && r.method === 'POST') + expect(commitRequest?.body).toEqual({ target_branch: 'pr-branch' }) + }) + }) + }) + + 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-create.test.ts b/tests/integration/commands/agents/agents-create.test.ts index 6ae89bc2368..1829817ad47 100644 --- a/tests/integration/commands/agents/agents-create.test.ts +++ b/tests/integration/commands/agents/agents-create.test.ts @@ -90,18 +90,13 @@ describe('agents:create command', () => { path: 'agent_runners', method: 'POST' as const, response: mockAgentRunner, - validateRequest: (request: { body: string }) => { - const body = JSON.parse(request.body) as { prompt: string; branch: string } - expect(body.prompt).toBe('Build a contact form') - expect(body.branch).toBe('main') - }, }, ] await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(routes, async ({ apiUrl }) => { + await withMockApi(routes, async ({ apiUrl, requests }) => { const childProcess = execa(cliPath, ['agents:create', '--agent', 'claude', '--branch', 'main'], { cwd: builder.directory, env: { NETLIFY_API_URL: apiUrl, NETLIFY_SITE_ID: 'site_id', NETLIFY_AUTH_TOKEN: 'fake-token' }, @@ -118,6 +113,8 @@ describe('agents:create command', () => { expect(result.stdout).toContain('Agent task created successfully!') expect(result.stdout).toContain('Prompt: Build a contact form') + const post = requests.find((r) => r.method === 'POST' && r.path.endsWith('/agent_runners')) + expect(post?.body).toMatchObject({ prompt: 'Build a contact form', branch: 'main' }) }) }) }) @@ -199,19 +196,13 @@ describe('agents:create command', () => { path: 'agent_runners', method: 'POST' as const, response: mockAgentRunnerNoRepo, - // Verify that no branch is sent in the request - validateRequest: (request: { body: string }) => { - const body = JSON.parse(request.body) as { prompt: string; branch?: string } - expect(body).not.toHaveProperty('branch') - expect(body.prompt).toBe('Add a contact form') - }, }, ] await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(routes, async ({ apiUrl }) => { + await withMockApi(routes, async ({ apiUrl, requests }) => { const { stdout } = await execa(cliPath, ['agents:create', 'Add a contact form', '--agent', 'claude'], { cwd: builder.directory, env: { NETLIFY_API_URL: apiUrl, NETLIFY_SITE_ID: 'zip_site_id', NETLIFY_AUTH_TOKEN: 'fake-token' }, @@ -223,6 +214,9 @@ describe('agents:create command', () => { expect(stdout).toContain('Agent: Claude') expect(stdout).toContain('Base: Latest production deployment') expect(stdout).not.toContain('Branch:') + const post = requests.find((r) => r.method === 'POST' && r.path.endsWith('/agent_runners')) + expect(post?.body).toMatchObject({ prompt: 'Add a contact form' }) + expect(post?.body).not.toHaveProperty('branch') }) }) }) @@ -234,19 +228,13 @@ describe('agents:create command', () => { path: 'agent_runners', method: 'POST' as const, response: mockAgentRunner, - // Verify that branch is sent in the request - validateRequest: (request: { body: string }) => { - const body = JSON.parse(request.body) as { prompt: string; branch: string } - expect(body.branch).toBe('feature-branch') - expect(body.prompt).toBe('Create a dashboard') - }, }, ] await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(routes, async ({ apiUrl }) => { + await withMockApi(routes, async ({ apiUrl, requests }) => { const { stdout } = await execa( cliPath, ['agents:create', 'Create a dashboard', '--agent', 'claude', '--branch', 'feature-branch'], @@ -259,6 +247,8 @@ describe('agents:create command', () => { expect(stdout).toContain('Agent task created successfully!') expect(stdout).toContain('Branch: feature-branch') expect(stdout).not.toContain('Base: Latest production deployment') + const post = requests.find((r) => r.method === 'POST' && r.path.endsWith('/agent_runners')) + expect(post?.body).toMatchObject({ prompt: 'Create a dashboard', branch: 'feature-branch' }) }) }) }) @@ -270,17 +260,13 @@ describe('agents:create command', () => { path: 'agent_runners', method: 'POST' as const, response: { ...mockAgentRunner, branch: 'develop' }, - validateRequest: (request: { body: string }) => { - const body = JSON.parse(request.body) as { branch: string } - expect(body.branch).toBe('develop') - }, }, ] await withSiteBuilder(t, async (builder) => { await builder.build() - await withMockApi(routes, async ({ apiUrl }) => { + await withMockApi(routes, async ({ apiUrl, requests }) => { const childProcess = execa(cliPath, ['agents:create', 'Create a form', '--agent', 'claude'], { cwd: builder.directory, env: { NETLIFY_API_URL: apiUrl, NETLIFY_SITE_ID: 'site_id', NETLIFY_AUTH_TOKEN: 'fake-token' }, @@ -297,6 +283,8 @@ describe('agents:create command', () => { expect(result.stdout).toContain('Agent task created successfully!') expect(result.stdout).toContain('Branch: develop') + const post = requests.find((r) => r.method === 'POST' && r.path.endsWith('/agent_runners')) + expect(post?.body).toMatchObject({ branch: 'develop' }) }) }) }) diff --git a/tests/integration/commands/agents/agents-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..3e5b2f089b0 --- /dev/null +++ b/tests/integration/commands/agents/agents-follow-up.test.ts @@ -0,0 +1,243 @@ +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 carry over agent and model from the latest done session', async (t) => { + const lastSession = { + ...mockAgentSession, + id: 'prev_id', + state: 'done', + agent_config: { agent: 'codex', model: 'gpt-4.1' }, + } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'GET' as const, + response: [lastSession], + }, + { + path: 'agent_runners/test_id/sessions', + method: 'POST' as const, + response: { ...mockAgentSession, agent_config: { agent: 'codex', model: 'gpt-4.1' } }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli(['agents:follow-up', 'test_id', 'Update README'], getCLIOptions({ apiUrl, builder })) + const sessionRequest = requests.find((r) => r.path.endsWith('/sessions') && r.method === 'POST') + expect(sessionRequest?.body).toMatchObject({ + prompt: 'Update README', + agent: 'codex', + model: 'gpt-4.1', + }) + }) + }) + }) + + test('should refuse to send a follow-up while the latest session is still running', async (t) => { + const activeSession = { ...mockAgentSession, state: 'running' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'GET' as const, + response: [activeSession], + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:follow-up', 'test_id', 'Add tests'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Cannot create a follow-up while a session is still active') + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + 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..c0f7ce4eb4b 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) => { @@ -130,15 +113,27 @@ describe('agents:list command', () => { 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() + expect(agentRequest?.originalUrl).toContain('state=running') expect(cliResponse).toContain('RUNNING') }) }) }) + test('should reject unsupported status values', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:list', '--status', 'live'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + '--status accepts only "running", "done", "error", "archived"', + ) + }) + }) + }) + test('should handle authentication errors', async (t) => { const routes = [ ...baseRoutes, @@ -183,10 +178,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 +204,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..ab31078559b --- /dev/null +++ b/tests/integration/commands/agents/agents-open.test.ts @@ -0,0 +1,215 @@ +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 explain when a PR is being created', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: { ...mockAgentRunner, pr_is_being_created: true }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id', 'pr'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('A pull request is being created') + }) + }) + }) + + test('should surface PR creation errors', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: { ...mockAgentRunner, pr_error: 'Repository not connected' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id', 'pr'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('Pull request creation failed: Repository not connected') + expect(cliResponse).toContain('netlify agents:pr test_id') + }) + }) + }) + + test('should reject invalid targets', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + 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..ea74d086e95 --- /dev/null +++ b/tests/integration/commands/agents/agents-publish.test.ts @@ -0,0 +1,205 @@ +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' }] }, + ] + + const runnerInSync = { + ...mockAgentRunner, + rebase_available: false, + merge_target_available: false, + needs_git_sync: false, + } + + test('should publish to production with --yes', async (t) => { + const runnerWithCommit = { ...runnerInSync, merge_commit_sha: 'def5678' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerInSync, + }, + { + path: 'agent_runners/test_id/publish_to_production', + method: 'POST' as const, + 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) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerInSync, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect(callCli(['agents:publish', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'Refusing to publish without --yes when stdin is not a TTY', + ) + }) + }) + }) + + test('should publish without --yes if --json is set (treats it as non-interactive)', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerInSync, + }, + { + path: 'agent_runners/test_id/publish_to_production', + method: 'POST' as const, + response: runnerInSync, + }, + ] + + 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 runnerInSync + + expect(cliResponse).toEqual(runnerInSync) + }) + }) + }) + + test('should refuse to publish an out-of-date run without --force', async (t) => { + const staleRunner = { ...mockAgentRunner, rebase_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: staleRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:publish', 'test_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Refusing to publish out-of-date run without --force') + }) + }) + }) + + test('should publish an out-of-date run with --force', async (t) => { + const staleRunner = { ...mockAgentRunner, rebase_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: staleRunner, + }, + { + path: 'agent_runners/test_id/publish_to_production', + method: 'POST' as const, + response: { ...staleRunner, merge_commit_sha: 'abc' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:publish', 'test_id', '--force', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Published agent task to production!') + }) + }) + }) + + test('should handle API errors', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerInSync, + }, + { + path: 'agent_runners/test_id/publish_to_production', + method: 'POST' as const, + 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-rename.test.ts b/tests/integration/commands/agents/agents-rename.test.ts new file mode 100644 index 00000000000..f50215850f2 --- /dev/null +++ b/tests/integration/commands/agents/agents-rename.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentRunner } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:rename command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should rename an agent task', async (t) => { + const renamed = { ...mockAgentRunner, title: 'New title' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'PATCH' as const, + response: renamed, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:rename', 'test_id', 'New title'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Agent task renamed.') + expect(cliResponse).toContain('Title: New title') + const patch = requests.find((r) => r.method === 'PATCH' && r.path.endsWith('/agent_runners/test_id')) + expect(patch?.body).toEqual({ title: 'New title' }) + }) + }) + }) + + test('should trim whitespace from the title', async (t) => { + const renamed = { ...mockAgentRunner, title: 'Trimmed title' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'PATCH' as const, + response: renamed, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:rename', 'test_id', ' Trimmed title '], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Title: Trimmed title') + const patch = requests.find((r) => r.method === 'PATCH' && r.path.endsWith('/agent_runners/test_id')) + expect(patch?.body).toEqual({ title: 'Trimmed title' }) + }) + }) + }) + + test('should reject empty titles', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:rename', 'test_id', ' '], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'A non-empty title is required', + ) + }) + }) + }) + + test('should reject titles longer than 200 chars', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + const longTitle = 'a'.repeat(201) + await expect( + callCli(['agents:rename', 'test_id', longTitle], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Title must be 200 characters or fewer') + }) + }) + }) + + test('should strip hidden Unicode tag characters before sending', async (t) => { + const renamed = { ...mockAgentRunner, title: 'Clean title' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'PATCH' as const, + response: renamed, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const tagChar = String.fromCodePoint(0xe0041) + const cliResponse = (await callCli( + ['agents:rename', 'test_id', `Clean${tagChar} title`], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Agent task renamed') + const patch = requests.find((r) => r.method === 'PATCH' && r.path.endsWith('/agent_runners/test_id')) + expect(patch?.body).toEqual({ title: 'Clean title' }) + }) + }) + }) + + test('should surface 404 when the task is missing', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/missing_id', + method: 'PATCH' as const, + status: 404, + response: { error: 'Not found' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:rename', 'missing_id', 'Title'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Agent task not found: missing_id') + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-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..7a9925274f3 100644 --- a/tests/integration/commands/agents/agents-show.test.ts +++ b/tests/integration/commands/agents/agents-show.test.ts @@ -56,7 +56,7 @@ describe('agents:show command', () => { expect(cliResponse).toContain('Site: site-name') expect(cliResponse).toContain('Agent: Claude') expect(cliResponse).toContain('Branch: main') - expect(cliResponse).toContain('Prompt: Create a login form') + expect(cliResponse).toContain('Title: Create a login form') }) }) }) @@ -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..c7894b97d0a 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', + 'Agent task not found: invalid_id', ) }) }) @@ -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/commands/agents/agents-sync.test.ts b/tests/integration/commands/agents/agents-sync.test.ts new file mode 100644 index 00000000000..bb3db42224f --- /dev/null +++ b/tests/integration/commands/agents/agents-sync.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentRunner } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:sync command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should rebase when only rebase is available', async (t) => { + const runner = { ...mockAgentRunner, rebase_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runner, + }, + { + path: 'agent_runners/test_id/rebase', + method: 'POST' as const, + response: runner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:sync', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Sync started') + expect(cliResponse).toContain('reapply changes on top of the latest production deploy') + const syncRequest = requests.find((r) => r.path.endsWith('/rebase')) + expect(syncRequest).toBeDefined() + }) + }) + }) + + test('should merge target when merge_target is available', async (t) => { + const runner = { ...mockAgentRunner, merge_target_available: true, rebase_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runner, + }, + { + path: 'agent_runners/test_id/merge_target', + method: 'POST' as const, + response: runner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:sync', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('merge the latest target branch') + const mergeRequest = requests.find((r) => r.path.endsWith('/merge_target')) + expect(mergeRequest).toBeDefined() + }) + }) + }) + + test('should sync git origin when needs_git_sync is set', async (t) => { + const runner = { ...mockAgentRunner, needs_git_sync: true, rebase_available: true, merge_target_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runner, + }, + { + path: 'agent_runners/test_id/sync_git_origin', + method: 'POST' as const, + response: runner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:sync', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('sync with the remote git origin') + const syncRequest = requests.find((r) => r.path.endsWith('/sync_git_origin')) + expect(syncRequest).toBeDefined() + }) + }) + }) + + test('should report when nothing needs syncing', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: mockAgentRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:sync', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Nothing to sync') + }) + }) + }) + + test('should surface 404 when the task is missing', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/missing_id', + method: 'GET' as const, + status: 404, + response: { error: 'Not found' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:sync', 'missing_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Agent task not found: missing_id') + }) + }) + }) +}) diff --git a/tests/integration/utils/mock-api.ts b/tests/integration/utils/mock-api.ts index eafd4795452..24ae081ce0c 100644 --- a/tests/integration/utils/mock-api.ts +++ b/tests/integration/utils/mock-api.ts @@ -21,7 +21,7 @@ interface MockApiOptions { export interface MockApi { apiUrl: string - requests: { path: string; body: unknown; method: string; headers: IncomingHttpHeaders }[] + requests: { path: string; originalUrl: string; body: unknown; method: string; headers: IncomingHttpHeaders }[] server: Server close: () => Promise<void> } @@ -34,6 +34,7 @@ export interface MockApiTestContext { const addRequest = (requests: MockApi['requests'], request: express.Request) => { requests.push({ path: request.path, + originalUrl: request.originalUrl, body: request.body, method: request.method, headers: request.headers, @@ -66,6 +67,10 @@ export const startMockApi = ({ routes, silent }: MockApiOptions): Promise<MockAp return } addRequest(requests, req) + if (typeof response === 'function') { + response(req, res) + return + } res.status(status) res.json(response) }, @@ -129,7 +134,7 @@ export const withMockApi = async ( routes: Route[], testHandler: (options: { apiUrl: string - requests: { path: string; body: unknown; method: string; headers: IncomingHttpHeaders }[] + requests: { path: string; originalUrl: string; body: unknown; method: string; headers: IncomingHttpHeaders }[] }) => Promise<void>, silent = false, ) => {