diff --git a/dev/src/cli/cli.ts b/dev/src/cli/cli.ts index 48a4cefb4..83e355287 100644 --- a/dev/src/cli/cli.ts +++ b/dev/src/cli/cli.ts @@ -102,6 +102,11 @@ function getBoolean(option?: string | boolean): boolean { * declared `[agents_dir]` argument followed by every token it did not * recognize; values of recognized options (`--port 9000`) are consumed during * parsing and never appear here. + * + * Commander discards the `--` end-of-options marker only while it is still + * collecting operands, so a `--` that follows an unrecognized flag survives in + * `command.args`. `gcloud run deploy` declares no trailing-remainder argument + * and fails with `unrecognized arguments: --`, so every bare marker is dropped. */ function getExtraGcloudArgs(command: Command): string[] { const extraArgs = [...command.args]; @@ -110,7 +115,20 @@ function getExtraGcloudArgs(command: Command): string[] { if (extraArgs.length > 0 && !extraArgs[0].startsWith('-')) { extraArgs.shift(); } - return extraArgs; + return extraArgs.filter((arg) => arg !== '--'); +} + +/** + * Resolves the `[agents_dir]` value of a command that forwards unknown flags. + * + * Commander binds the first unmatched token to the declared argument even when + * that token is an unknown flag being forwarded, so an omitted directory would + * otherwise resolve the deploy source to `/--some-gcloud-flag`. An agent + * path never starts with `-` (a relative one is spelled `./-name`), so fall + * back to the argument's own default. + */ +function resolveAgentPath(agentsDir: string): string { + return getAbsolutePath(agentsDir.startsWith('-') ? process.cwd() : agentsDir); } const AGENT_DIR_ARGUMENT = new Argument( @@ -210,6 +228,19 @@ export const AGENT_ENGINE_ID_OPTION = new Option( 'Optional. ID of the Agent Engine instance to update if it exists (default: undefined, which means a new instance will be created). If project and region are set, this should be the resource ID or the full resource name (projects/.../locations/.../reasoningEngines/...).', ); +const CLOUD_RUN_HELP_EPILOG = ` +Any option that is not listed above is forwarded verbatim to "gcloud run deploy". +Use -- to separate gcloud arguments from adk arguments. + +Examples: + adk deploy cloud_run --project=[project] --region=[region] path/to/my_agent + adk deploy cloud_run path/to/my_agent -- --min-instances=2 + +ADK sets --source, --project, --port, --verbosity and --region itself, and also +reserves the gcloud env-var flags (--update-env-vars, --set-env-vars, +--remove-env-vars, --clear-env-vars, --env-vars-file) when --a2a_auth_token is +set. Passing a reserved flag as a gcloud argument is rejected.`; + /** * Creates the ADK CLI program. * @returns The ADK CLI program. @@ -415,6 +446,7 @@ export function createProgram(): Command { .allowExcessArguments(); DEPLOY_COMMAND.command('cloud_run') + .description('Deploys an agent to Cloud Run') .addArgument(AGENT_DIR_ARGUMENT) .allowUnknownOption() .allowExcessArguments() @@ -443,6 +475,7 @@ export function createProgram(): Command { .addOption(AGENT_FILE_MODULE_TYPE) .addOption(A2A_OPTION) .addOption(A2A_AUTH_TOKEN_DEPLOY_OPTION) + .addHelpText('after', CLOUD_RUN_HELP_EPILOG) .action( async ( agentPath: string, @@ -453,7 +486,7 @@ export function createProgram(): Command { try { await deployToCloudRun({ - agentPath: getAbsolutePath(agentPath), + agentPath: resolveAgentPath(agentPath), project: options['project'], region: options['region'], serviceName: options['service_name'], @@ -478,6 +511,7 @@ export function createProgram(): Command { const registerAgentEngineCommand = (cmd: Command) => { cmd + .description('Deploys an agent to Vertex AI Agent Engine') .addArgument(AGENT_DIR_ARGUMENT) .allowUnknownOption() .allowExcessArguments() diff --git a/dev/test/cli/cli_test.ts b/dev/test/cli/cli_test.ts index 7df1aa90c..6882c6dab 100644 --- a/dev/test/cli/cli_test.ts +++ b/dev/test/cli/cli_test.ts @@ -5,6 +5,7 @@ */ import {LogLevel, setLogLevel} from '@google/adk'; +import type {Command} from 'commander'; import {afterEach, beforeEach, describe, expect, it, Mock, vi} from 'vitest'; import {createProgram} from '../../src/cli/cli.js'; import {createAgent} from '../../src/cli/cli_create.js'; @@ -49,6 +50,39 @@ vi.mock('@google/adk', async (importOriginal) => { }; }); +/** + * Runs `--help` for the command at `commandPath` (e.g. `['deploy', + * 'cloud_run']`) and returns everything commander printed, asserting the help + * request exited with code 0. + * + * The output is captured on the command that renders it: `configureOutput` + * rebinds only that command's output configuration, and the epilog registered + * with `addHelpText` is emitted by `outputHelp`, not by `helpInformation`. + */ +async function captureHelp( + program: Command, + commandPath: string[], +): Promise { + let command = program; + for (const name of commandPath) { + const child = command.commands.find((c) => c.name() === name); + if (!child) { + expect.fail(`Command "${commandPath.join(' ')}" is not registered`); + } + command = child; + } + + let help = ''; + command.configureOutput({writeOut: (str) => (help += str)}); + command.exitOverride(); + + await expect( + program.parseAsync(['node', 'cli_entrypoint.js', ...commandPath, '--help']), + ).rejects.toMatchObject({code: 'commander.helpDisplayed', exitCode: 0}); + + return help; +} + describe('CLI Entrypoint', () => { let program: ReturnType; @@ -263,7 +297,100 @@ describe('CLI Entrypoint', () => { }); }); + describe('command: deploy', () => { + it('should list every deploy subcommand with a description', async () => { + const help = (await captureHelp(program, ['deploy'])).replace( + /\s+/g, + ' ', + ); + + expect(help).toContain( + 'cloud_run [options] [agents_dir] Deploys an agent to Cloud Run', + ); + expect(help).toContain( + 'agent_engine [options] [agents_dir] Deploys an agent to Vertex AI Agent Engine', + ); + expect(help).toContain( + 'reasoning_engine [options] [agents_dir] Deploys an agent to Vertex AI Agent Engine', + ); + }); + }); + describe('command: deploy cloud_run', () => { + it('should document the gcloud pass-through contract in its help', async () => { + const help = await captureHelp(program, ['deploy', 'cloud_run']); + const normalized = help.replace(/\s+/g, ' '); + + expect(help).toContain('Deploys an agent to Cloud Run'); + expect(help).toContain( + 'Any option that is not listed above is forwarded verbatim to "gcloud run deploy".', + ); + expect(help).toContain( + 'Use -- to separate gcloud arguments from adk arguments.', + ); + expect(help).toContain( + 'adk deploy cloud_run path/to/my_agent -- --min-instances=2', + ); + expect(normalized).toContain( + 'ADK sets --source, --project, --port, --verbosity and --region itself', + ); + expect(normalized).toContain( + 'the gcloud env-var flags (--update-env-vars, --set-env-vars, --remove-env-vars, --clear-env-vars, --env-vars-file) when --a2a_auth_token is set', + ); + // The epilog documents the option list, so it has to follow it. + expect( + help.indexOf('Any option that is not listed above'), + ).toBeGreaterThan(help.indexOf('-h, --help')); + expect(deployToCloudRun).not.toHaveBeenCalled(); + }); + + it('should forward the -- example printed in its help', async () => { + await parse([ + 'deploy', + 'cloud_run', + 'path/to/my_agent', + '--', + '--min-instances=2', + ]); + + expect( + (deployToCloudRun as Mock).mock.calls[0][0].extraGcloudArgs, + ).toEqual(['--min-instances=2']); + }); + + it('should drop the -- separator when a gcloud flag precedes it', async () => { + await parse([ + 'deploy', + 'cloud_run', + './my-agent-path', + '--no-allow-unauthenticated', + '--', + '--min-instances=2', + ]); + + expect( + (deployToCloudRun as Mock).mock.calls[0][0].extraGcloudArgs, + ).toEqual(['--no-allow-unauthenticated', '--min-instances=2']); + }); + + it('should drop every bare -- from the forwarded gcloud args', async () => { + await parse([ + 'deploy', + 'cloud_run', + './my-agent-path', + '--memory', + '512Mi', + '--', + '--min-instances=2', + '--', + '--cpu=2', + ]); + + expect( + (deployToCloudRun as Mock).mock.calls[0][0].extraGcloudArgs, + ).toEqual(['--memory', '512Mi', '--min-instances=2', '--cpu=2']); + }); + it('should call deployToCloudRun with defaults', async () => { await parse(['deploy', 'cloud_run']); @@ -444,6 +571,21 @@ describe('CLI Entrypoint', () => { '--allow-unauthenticated', ]); }); + + it('should not deploy a leading gcloud flag as the agent directory', async () => { + await parse(['deploy', 'cloud_run', '--allow-unauthenticated']); + + expect(deployedWith().agentPath).toBe(process.cwd()); + }); + + it('should support the -- example with the agent directory omitted', async () => { + await parse(['deploy', 'cloud_run', '--', '--min-instances=2']); + + expect(deployedWith()).toMatchObject({ + agentPath: process.cwd(), + extraGcloudArgs: ['--min-instances=2'], + }); + }); }); describe('command: deploy agent_engine', () => {