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
38 changes: 36 additions & 2 deletions dev/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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 `<cwd>/--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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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'],
Expand All @@ -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()
Expand Down
142 changes: 142 additions & 0 deletions dev/test/cli/cli_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string> {
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<typeof createProgram>;

Expand Down Expand Up @@ -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']);

Expand Down Expand Up @@ -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', () => {
Expand Down