diff --git a/docs/commands/agents.md b/docs/commands/agents.md index b6898dced1a..f54133a36f0 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -27,6 +27,7 @@ netlify agents | Subcommand | description | |:--------------------------- |:-----| +| [`agents:archive`](/commands/agents#agentsarchive) | Archive an agent run | | [`agents:commit`](/commands/agents#agentscommit) | Commit an agent run’s changes directly to a branch | | [`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 | @@ -35,6 +36,7 @@ netlify agents | [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent run | | [`agents:publish`](/commands/agents#agentspublish) | Publish an agent run’s changes to production | | [`agents:redeploy`](/commands/agents#agentsredeploy) | Redeploy an agent run by reapplying its existing changes (no AI inference) | +| [`agents:rename`](/commands/agents#agentsrename) | Rename an agent run | | [`agents:revert`](/commands/agents#agentsrevert) | Revert an agent run to a specific session (sessions after it are discarded) | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent run | | [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent run | @@ -51,6 +53,37 @@ netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a netlify agents:open 60c7c3b3e7b4a0001f5e4b3a ``` +--- +## `agents:archive` + +Archive an agent run + +**Usage** + +```bash +netlify agents:archive +``` + +**Arguments** + +- id - agent run 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` @@ -322,6 +355,36 @@ netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a --session 70d8... ``` +--- +## `agents:rename` + +Rename an agent run + +**Usage** + +```bash +netlify agents:rename +``` + +**Arguments** + +- id - agent run ID +- title - new title for the agent run + +**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` diff --git a/docs/index.md b/docs/index.md index fa7094a7e5c..0abb807eaf6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,6 +24,7 @@ Manage Netlify AI agent runs | Subcommand | description | |:--------------------------- |:-----| +| [`agents:archive`](/commands/agents#agentsarchive) | Archive an agent run | | [`agents:commit`](/commands/agents#agentscommit) | Commit an agent run’s changes directly to a branch | | [`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 | @@ -32,6 +33,7 @@ Manage Netlify AI agent runs | [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent run | | [`agents:publish`](/commands/agents#agentspublish) | Publish an agent run’s changes to production | | [`agents:redeploy`](/commands/agents#agentsredeploy) | Redeploy an agent run by reapplying its existing changes (no AI inference) | +| [`agents:rename`](/commands/agents#agentsrename) | Rename an agent run | | [`agents:revert`](/commands/agents#agentsrevert) | Revert an agent run to a specific session (sessions after it are discarded) | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent run | | [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent run | diff --git a/src/commands/agents/agents-archive.ts b/src/commands/agents/agents-archive.ts new file mode 100644 index 00000000000..b2a5cfa619c --- /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 run 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 run ${id}?`, + default: false, + }, + ]) + if (!confirmed) return exit() + } + + const spinner = startSpinner({ text: 'Archiving agent run...' }) + try { + await api.archiveAgentRunner(id) + stopSpinner({ spinner }) + + const result = { success: true, id } + if (options.json) { + logJson(result) + return result + } + + log(`${chalk.green('✓')} Agent run archived.`) + log(` Run 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 run not found: ${id}`) + return logAndThrowError(`Failed to archive: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-rename.ts b/src/commands/agents/agents-rename.ts new file mode 100644 index 00000000000..d38c02a82c4 --- /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 run 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 run...' }) + try { + const runner = await api.updateAgentRunner(id, { title: sanitized }) + stopSpinner({ spinner }) + + if (options.json) { + logJson(runner) + return runner + } + + log(`${chalk.green('✓')} Agent run renamed.`) + log(` Run 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 run not found: ${id}`) + return logAndThrowError(`Failed to rename: ${error.message}`) + } +} diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index 1cd534bca39..d0fdbcc4c79 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -201,6 +201,23 @@ export const createAgentsCommand = (program: BaseCommand) => { await agentsRevert(id, options, command) }) + program + .command('agents:archive') + .argument('', 'agent run ID') + .description('Archive an agent run') + .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 run ID') @@ -218,6 +235,20 @@ export const createAgentsCommand = (program: BaseCommand) => { await agentsRedeploy(id, options, command) }) + program + .command('agents:rename') + .argument('', 'agent run ID') + .argument('', 'new title for the agent run') + .description('Rename an agent run') + .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 run ID') 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..6be8e3d637a --- /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 run 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 run archived.') + expect(cliResponse).toContain('Run 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 run 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-rename.test.ts b/tests/integration/commands/agents/agents-rename.test.ts new file mode 100644 index 00000000000..ce9eaa77a01 --- /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 run', 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 run 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 run 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 run not found: missing_id') + }) + }) + }) +})