Skip to content

Commit c6e77a5

Browse files
feat(agents): add diff and open commands
- agents:diff renders the change set for an agent run (with --no-strip-binary to keep binary patches) - agents:open opens the run's preview, dashboard, or PR in the browser, honoring NETLIFY_WEB_UI for staging Part 3/8 of splitting #8237.
1 parent 79a1657 commit c6e77a5

7 files changed

Lines changed: 705 additions & 0 deletions

File tree

docs/commands/agents.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ netlify agents
2828
| Subcommand | description |
2929
|:--------------------------- |:-----|
3030
| [`agents:create`](/commands/agents#agentscreate) | Create and start a new agent run on your site |
31+
| [`agents:diff`](/commands/agents#agentsdiff) | Print the code changes produced by an agent run |
3132
| [`agents:list`](/commands/agents#agentslist) | List agent runs for the current site |
33+
| [`agents:open`](/commands/agents#agentsopen) | Open the agent run preview, dashboard, or pull request in a browser |
3234
| [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent run |
3335
| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent run |
3436

@@ -39,6 +41,8 @@ netlify agents
3941
netlify agents:create --prompt "Add a contact form"
4042
netlify agents:list --status running
4143
netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch
44+
netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a
45+
netlify agents:open 60c7c3b3e7b4a0001f5e4b3a
4246
```
4347

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

88+
---
89+
## `agents:diff`
90+
91+
Print the code changes produced by an agent run
92+
93+
**Usage**
94+
95+
```bash
96+
netlify agents:diff
97+
```
98+
99+
**Arguments**
100+
101+
- id - agent run ID
102+
103+
**Flags**
104+
105+
- `cumulative` (*boolean*) - with --session, show the cumulative diff up through that session
106+
- `filter` (*string*) - For monorepos, specify the name of the application to run the command in
107+
- `no-color` (*boolean*) - disable color in the output
108+
- `no-strip-binary` (*boolean*) - include raw binary content in the diff (binary is stripped by default)
109+
- `page` (*string*) - page number (1-based)
110+
- `per-page` (*string*) - files per page (max 100)
111+
- `debug` (*boolean*) - Print debugging information
112+
- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in
113+
- `project` (*string*) - project ID or name (if not in a linked directory)
114+
- `session` (*string*) - show a single session diff instead of the run aggregate
115+
116+
**Examples**
117+
118+
```bash
119+
netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a
120+
netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --page 2
121+
netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --session 70d8... --cumulative
122+
netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --no-color | less
123+
```
124+
84125
---
85126
## `agents:list`
86127

@@ -121,6 +162,37 @@ netlify agents:list --account my-team
121162
netlify agents:list --ndjson
122163
```
123164

165+
---
166+
## `agents:open`
167+
168+
Open the agent run preview, dashboard, or pull request in a browser
169+
170+
**Usage**
171+
172+
```bash
173+
netlify agents:open
174+
```
175+
176+
**Arguments**
177+
178+
- id - agent run ID to open
179+
- target - what to open: preview (default), dashboard, or pr
180+
181+
**Flags**
182+
183+
- `filter` (*string*) - For monorepos, specify the name of the application to run the command in
184+
- `project` (*string*) - project ID or name (if not in a linked directory)
185+
- `debug` (*boolean*) - Print debugging information
186+
- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in
187+
188+
**Examples**
189+
190+
```bash
191+
netlify agents:open 60c7c3b3e7b4a0001f5e4b3a
192+
netlify agents:open 60c7c3b3e7b4a0001f5e4b3a dashboard
193+
netlify agents:open 60c7c3b3e7b4a0001f5e4b3a pr
194+
```
195+
124196
---
125197
## `agents:show`
126198

docs/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ Manage Netlify AI agent runs
2525
| Subcommand | description |
2626
|:--------------------------- |:-----|
2727
| [`agents:create`](/commands/agents#agentscreate) | Create and start a new agent run on your site |
28+
| [`agents:diff`](/commands/agents#agentsdiff) | Print the code changes produced by an agent run |
2829
| [`agents:list`](/commands/agents#agentslist) | List agent runs for the current site |
30+
| [`agents:open`](/commands/agents#agentsopen) | Open the agent run preview, dashboard, or pull request in a browser |
2931
| [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent run |
3032
| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent run |
3133

src/commands/agents/agents-diff.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type { OptionValues } from 'commander'
2+
3+
import { chalk, log, logAndThrowError } from '../../utils/command-helpers.js'
4+
import { startSpinner, stopSpinner } from '../../lib/spinner.js'
5+
import type BaseCommand from '../base-command.js'
6+
import { createAgentsApi, type AgentsApi } from './api.js'
7+
import { formatDiff } from './utils.js'
8+
9+
interface AgentDiffOptions extends OptionValues {
10+
page?: string
11+
perPage?: string
12+
session?: string
13+
cumulative?: boolean
14+
stripBinary?: boolean
15+
color?: boolean
16+
}
17+
18+
const parsePositiveInt = (input: string | undefined, name: string): number | undefined => {
19+
if (input === undefined) return undefined
20+
if (!/^[1-9]\d*$/.test(input)) {
21+
throw new Error(`--${name} must be a positive integer`)
22+
}
23+
return Number.parseInt(input, 10)
24+
}
25+
26+
const verifyRunnerExists = async (api: AgentsApi, id: string): Promise<void> => {
27+
try {
28+
await api.getAgentRunner(id)
29+
} catch (error_) {
30+
const error = error_ as Error & { status?: number }
31+
if (error.status === 404) {
32+
throw new Error(`Agent run not found: ${id}`)
33+
}
34+
throw error
35+
}
36+
}
37+
38+
export const agentsDiff = async (id: string, options: AgentDiffOptions, command: BaseCommand) => {
39+
if (!id) return logAndThrowError('Agent run ID is required')
40+
await command.authenticate()
41+
const api = createAgentsApi(command.netlify)
42+
43+
const useColor = options.color !== false && process.stdout.isTTY
44+
45+
if (options.session) {
46+
const kind = options.cumulative ? 'cumulative' : 'result'
47+
const spinner = startSpinner({ text: `Fetching session ${kind} diff...` })
48+
try {
49+
const diff = options.cumulative
50+
? await api.getSessionCumulativeDiff(id, options.session)
51+
: await api.getSessionResultDiff(id, options.session)
52+
stopSpinner({ spinner })
53+
if (!diff) {
54+
await verifyRunnerExists(api, id)
55+
log(chalk.yellow('No diff available for this session.'))
56+
return
57+
}
58+
process.stdout.write(useColor ? formatDiff(diff) : diff)
59+
if (!diff.endsWith('\n')) process.stdout.write('\n')
60+
return
61+
} catch (error_) {
62+
stopSpinner({ spinner, error: true })
63+
const error = error_ as Error
64+
if (error.message.startsWith('Agent run not found:')) {
65+
return logAndThrowError(error.message)
66+
}
67+
return logAndThrowError(`Failed to fetch diff: ${error.message}`)
68+
}
69+
}
70+
71+
let page: number | undefined
72+
let perPage: number | undefined
73+
try {
74+
page = parsePositiveInt(options.page, 'page') ?? 1
75+
perPage = parsePositiveInt(options.perPage, 'per-page')
76+
} catch (error_) {
77+
return logAndThrowError((error_ as Error).message)
78+
}
79+
80+
const spinner = startSpinner({ text: 'Fetching agent run diff...' })
81+
try {
82+
const result = await api.getAgentRunnerDiff(id, {
83+
page,
84+
per_page: perPage,
85+
strip_binary: options.stripBinary !== false,
86+
})
87+
stopSpinner({ spinner })
88+
89+
if (!result.data) {
90+
await verifyRunnerExists(api, id)
91+
log(chalk.yellow('No diff available for this agent run.'))
92+
return
93+
}
94+
95+
process.stdout.write(useColor ? formatDiff(result.data) : result.data)
96+
if (!result.data.endsWith('\n')) process.stdout.write('\n')
97+
98+
log()
99+
log(chalk.dim(formatFooter(result.page, result.perPage, result.total, result.hasNext)))
100+
return result
101+
} catch (error_) {
102+
stopSpinner({ spinner, error: true })
103+
const error = error_ as Error
104+
if (error.message.startsWith('Agent run not found:')) {
105+
return logAndThrowError(error.message)
106+
}
107+
return logAndThrowError(`Failed to fetch diff: ${error.message}`)
108+
}
109+
}
110+
111+
const formatFooter = (page: number, perPage: number, total: number | undefined, hasNext: boolean): string => {
112+
const parts: string[] = []
113+
if (total != null) {
114+
const start = (page - 1) * perPage + 1
115+
const end = Math.min(page * perPage, total)
116+
parts.push(`Showing files ${start.toString()}-${end.toString()} of ${total.toString()}`)
117+
} else {
118+
parts.push(`Showing page ${page.toString()}`)
119+
}
120+
if (hasNext) {
121+
parts.push(`Use --page ${(page + 1).toString()} for the next page`)
122+
}
123+
return parts.join(' • ')
124+
}

src/commands/agents/agents-open.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { OptionValues } from 'commander'
2+
3+
import { chalk, log, logAndThrowError } from '../../utils/command-helpers.js'
4+
import { startSpinner, stopSpinner } from '../../lib/spinner.js'
5+
import openBrowser from '../../utils/open-browser.js'
6+
import type BaseCommand from '../base-command.js'
7+
import { createAgentsApi } from './api.js'
8+
import { buildAgentDashboardUrl } from './utils.js'
9+
10+
const VALID_TARGETS = ['preview', 'dashboard', 'pr'] as const
11+
type OpenTarget = (typeof VALID_TARGETS)[number]
12+
13+
const isOpenTarget = (input: string): input is OpenTarget => (VALID_TARGETS as readonly string[]).includes(input)
14+
15+
interface AgentOpenOptions extends OptionValues {
16+
json?: boolean
17+
}
18+
19+
export const agentsOpen = async (
20+
id: string,
21+
targetArg: string | undefined,
22+
_options: AgentOpenOptions,
23+
command: BaseCommand,
24+
) => {
25+
if (!id) return logAndThrowError('Agent run ID is required')
26+
27+
const candidate = targetArg ?? 'preview'
28+
if (!isOpenTarget(candidate)) {
29+
return logAndThrowError(`Invalid target "${candidate}". Choose one of: ${VALID_TARGETS.join(', ')}`)
30+
}
31+
const target: OpenTarget = candidate
32+
33+
await command.authenticate()
34+
const { siteInfo } = command.netlify
35+
const api = createAgentsApi(command.netlify)
36+
const dashboardUrl = buildAgentDashboardUrl(siteInfo.name, id)
37+
38+
if (target === 'dashboard') {
39+
return openUrl(dashboardUrl)
40+
}
41+
42+
const spinner = startSpinner({ text: 'Looking up agent run...' })
43+
let runner
44+
try {
45+
runner = await api.getAgentRunner(id)
46+
stopSpinner({ spinner })
47+
} catch (error_) {
48+
stopSpinner({ spinner, error: true })
49+
const error = error_ as Error & { status?: number }
50+
if (error.status === 404) return logAndThrowError(`Agent run not found: ${id}`)
51+
return logAndThrowError(`Failed to fetch agent run: ${error.message}`)
52+
}
53+
54+
if (target === 'pr') {
55+
if (runner.pr_url) return openUrl(runner.pr_url)
56+
if (runner.pr_is_being_created) {
57+
log(chalk.yellow('A pull request is being created. Try again in a moment.'))
58+
return
59+
}
60+
if (runner.pr_error) {
61+
log(chalk.red(`Pull request creation failed: ${runner.pr_error}`))
62+
log(`Retry with: ${chalk.cyan(`netlify agents:pr ${id}`)}`)
63+
return
64+
}
65+
log(chalk.yellow('No pull request exists for this agent run.'))
66+
log(`Create one with: ${chalk.cyan(`netlify agents:pr ${id}`)}`)
67+
return
68+
}
69+
70+
const previewUrl = runner.latest_session_deploy_url
71+
if (!previewUrl) {
72+
log(chalk.yellow('No deploy preview available yet — opening dashboard instead.'))
73+
return openUrl(dashboardUrl)
74+
}
75+
return openUrl(previewUrl)
76+
}
77+
78+
const openUrl = async (url: string): Promise<void> => {
79+
log(`Opening ${chalk.blue(url)}`)
80+
await openBrowser({ url })
81+
}

src/commands/agents/agents.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,46 @@ export const createAgentsCommand = (program: BaseCommand) => {
100100
await agentsStop(id, options, command)
101101
})
102102

103+
program
104+
.command('agents:open')
105+
.argument('<id>', 'agent run ID to open')
106+
.argument('[target]', 'what to open: preview (default), dashboard, or pr', 'preview')
107+
.description('Open the agent run preview, dashboard, or pull request in a browser')
108+
.option('--project <project>', 'project ID or name (if not in a linked directory)')
109+
.hook('preAction', requiresSiteInfoWithProject)
110+
.addExamples([
111+
'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a',
112+
'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a dashboard',
113+
'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a pr',
114+
])
115+
.action(async (id: string, target: string | undefined, options: OptionValues, command: BaseCommand) => {
116+
const { agentsOpen } = await import('./agents-open.js')
117+
await agentsOpen(id, target, options, command)
118+
})
119+
120+
program
121+
.command('agents:diff')
122+
.argument('<id>', 'agent run ID')
123+
.description('Print the code changes produced by an agent run')
124+
.option('--page <n>', 'page number (1-based)')
125+
.option('--per-page <n>', 'files per page (max 100)')
126+
.option('--session <sid>', 'show a single session diff instead of the run aggregate')
127+
.option('--cumulative', 'with --session, show the cumulative diff up through that session')
128+
.option('--no-strip-binary', 'include raw binary content in the diff (binary is stripped by default)')
129+
.option('--no-color', 'disable color in the output')
130+
.option('--project <project>', 'project ID or name (if not in a linked directory)')
131+
.hook('preAction', requiresSiteInfoWithProject)
132+
.addExamples([
133+
'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a',
134+
'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --page 2',
135+
'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --session 70d8... --cumulative',
136+
'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --no-color | less',
137+
])
138+
.action(async (id: string, options: OptionValues, command: BaseCommand) => {
139+
const { agentsDiff } = await import('./agents-diff.js')
140+
await agentsDiff(id, options, command)
141+
})
142+
103143
const name = chalk.greenBright('`agents`')
104144

105145
return program
@@ -114,6 +154,8 @@ Note: Agent runs execute remotely on Netlify infrastructure, not locally.`,
114154
'netlify agents:create --prompt "Add a contact form"',
115155
'netlify agents:list --status running',
116156
'netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch',
157+
'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a',
158+
'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a',
117159
])
118160
.action(agents)
119161
}

0 commit comments

Comments
 (0)