Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions docs/commands/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ netlify agents
| Subcommand | description |
|:--------------------------- |:-----|
| [`agents:create`](/commands/agents#agentscreate) | Create and start a new agent run on your site |
| [`agents:diff`](/commands/agents#agentsdiff) | Print the code changes produced by an agent run |
| [`agents:list`](/commands/agents#agentslist) | List agent runs for the current site |
| [`agents:open`](/commands/agents#agentsopen) | Open the agent run preview, dashboard, or pull request in a browser |
| [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent run |
| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent run |

Expand All @@ -39,6 +41,8 @@ netlify agents
netlify agents:create --prompt "Add a contact form"
netlify agents:list --status running
netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch
netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a
netlify agents:open 60c7c3b3e7b4a0001f5e4b3a
```

---
Expand Down Expand Up @@ -81,6 +85,43 @@ netlify agents:create -p "Update README" -a codex -b feature-branch
netlify agents:create "Triage this error" --attach error.log --attach screenshot.png
```

---
## `agents:diff`

Print the code changes produced by an agent run

**Usage**

```bash
netlify agents:diff
```

**Arguments**

- id - agent run 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 run 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:list`

Expand Down Expand Up @@ -121,6 +162,37 @@ netlify agents:list --account my-team
netlify agents:list --ndjson
```

---
## `agents:open`

Open the agent run preview, dashboard, or pull request in a browser

**Usage**

```bash
netlify agents:open
```

**Arguments**

- id - agent run 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:show`

Expand Down
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ Manage Netlify AI agent runs
| Subcommand | description |
|:--------------------------- |:-----|
| [`agents:create`](/commands/agents#agentscreate) | Create and start a new agent run on your site |
| [`agents:diff`](/commands/agents#agentsdiff) | Print the code changes produced by an agent run |
| [`agents:list`](/commands/agents#agentslist) | List agent runs for the current site |
| [`agents:open`](/commands/agents#agentsopen) | Open the agent run preview, dashboard, or pull request in a browser |
| [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent run |
| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent run |

Expand Down
124 changes: 124 additions & 0 deletions src/commands/agents/agents-diff.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
try {
await api.getAgentRunner(id)
} catch (error_) {
const error = error_ as Error & { status?: number }
if (error.status === 404) {
throw new Error(`Agent run not found: ${id}`)
}
throw error
}
}

export const agentsDiff = async (id: string, options: AgentDiffOptions, command: BaseCommand) => {
if (!id) return logAndThrowError('Agent run 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 run 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 run 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 run.'))
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 run 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(' • ')
}
81 changes: 81 additions & 0 deletions src/commands/agents/agents-open.ts
Original file line number Diff line number Diff line change
@@ -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 run 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 run...' })
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 run not found: ${id}`)
return logAndThrowError(`Failed to fetch agent run: ${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 run.'))
log(`Create one with: ${chalk.cyan(`netlify agents:pr ${id}`)}`)
return
Comment thread
VaibhavAcharya marked this conversation as resolved.
}

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<void> => {
log(`Opening ${chalk.blue(url)}`)
await openBrowser({ url })
}
42 changes: 42 additions & 0 deletions src/commands/agents/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,46 @@ export const createAgentsCommand = (program: BaseCommand) => {
await agentsStop(id, options, command)
})

program
.command('agents:open')
.argument('<id>', 'agent run ID to open')
.argument('[target]', 'what to open: preview (default), dashboard, or pr', 'preview')
.description('Open the agent run preview, dashboard, or pull request in a browser')
.option('--project <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('<id>', 'agent run ID')
.description('Print the code changes produced by an agent run')
.option('--page <n>', 'page number (1-based)')
.option('--per-page <n>', 'files per page (max 100)')
.option('--session <sid>', 'show a single session diff instead of the run 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>', '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)
})

const name = chalk.greenBright('`agents`')

return program
Expand All @@ -114,6 +154,8 @@ Note: Agent runs execute remotely on Netlify infrastructure, not locally.`,
'netlify agents:create --prompt "Add a contact form"',
'netlify agents:list --status running',
'netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch',
'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a',
'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a',
])
.action(agents)
}
Loading
Loading