diff --git a/docs/extensions/index.md b/docs/extensions/index.md index bb0721676c..8999dc4e7d 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -18,7 +18,7 @@ Note that all of these commands will only be reflected in active CLI sessions on ### Installing an extension -You can install an extension using `gemini extensions install` with either a GitHub URL source or `--path=some/local/path`. +You can install an extension using `gemini extensions install` with either a GitHub URL or a local path`. Note that we create a copy of the installed extension, so you will need to run `gemini extensions update` to pull in changes from both locally-defined extensions and those on GitHub. diff --git a/integration-tests/extensions-install.test.ts b/integration-tests/extensions-install.test.ts index 3a94167706..c54f94b12a 100644 --- a/integration-tests/extensions-install.test.ts +++ b/integration-tests/extensions-install.test.ts @@ -31,7 +31,7 @@ test('installs a local extension, verifies a command, and updates it', async () } const result = await rig.runCommand( - ['extensions', 'install', `--path=${rig.testDir!}`], + ['extensions', 'install', `${rig.testDir!}`], { stdin: 'y\n' }, ); expect(result).toContain('test-extension'); diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 347d074ad6..3e479d7649 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -10,6 +10,7 @@ import yargs from 'yargs'; const mockInstallExtension = vi.hoisted(() => vi.fn()); const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); +const mockStat = vi.hoisted(() => vi.fn()); vi.mock('../../config/extension.js', () => ({ installExtension: mockInstallExtension, @@ -20,35 +21,20 @@ vi.mock('../../utils/errors.js', () => ({ getErrorMessage: vi.fn((error: Error) => error.message), })); +vi.mock('node:fs/promises', () => ({ + stat: mockStat, + default: { + stat: mockStat, + }, +})); + describe('extensions install command', () => { it('should fail if no source is provided', () => { const validationParser = yargs([]).command(installCommand).fail(false); expect(() => validationParser.parse('install')).toThrow( - 'Either source or --path must be provided.', + 'Not enough non-option arguments: got 0, need at least 1', ); }); - - it('should fail if both git source and local path are provided', () => { - const validationParser = yargs([]) - .command(installCommand) - .fail(false) - .locale('en'); - expect(() => - validationParser.parse('install some-url --path /some/path'), - ).toThrow('Arguments source and path are mutually exclusive'); - }); - - it('should fail if both auto update and local path are provided', () => { - const validationParser = yargs([]) - .command(installCommand) - .fail(false) - .locale('en'); - expect(() => - validationParser.parse( - 'install some-url --path /some/path --auto-update', - ), - ).toThrow('Arguments path and auto-update are mutually exclusive'); - }); }); describe('handleInstall', () => { @@ -67,6 +53,7 @@ describe('handleInstall', () => { afterEach(() => { mockInstallExtension.mockClear(); mockRequestConsentNonInteractive.mockClear(); + mockStat.mockClear(); vi.resetAllMocks(); }); @@ -107,13 +94,12 @@ describe('handleInstall', () => { }); it('throws an error from an unknown source', async () => { + mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory')); await handleInstall({ source: 'test://google.com', }); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'The source "test://google.com" is not a valid URL format.', - ); + expect(consoleErrorSpy).toHaveBeenCalledWith('Install source not found.'); expect(processSpy).toHaveBeenCalledWith(1); }); @@ -131,9 +117,9 @@ describe('handleInstall', () => { it('should install an extension from a local path', async () => { mockInstallExtension.mockResolvedValue('local-extension'); - + mockStat.mockResolvedValue({}); await handleInstall({ - path: '/some/path', + source: '/some/path', }); expect(consoleLogSpy).toHaveBeenCalledWith( @@ -141,15 +127,6 @@ describe('handleInstall', () => { ); }); - it('should throw an error if no source or path is provided', async () => { - await handleInstall({}); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Either --source or --path must be provided.', - ); - expect(processSpy).toHaveBeenCalledWith(1); - }); - it('should throw an error if install extension fails', async () => { mockInstallExtension.mockRejectedValue( new Error('Install extension failed'), diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 6fef7a0b14..6cdaaec823 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -10,12 +10,11 @@ import { requestConsentNonInteractive, } from '../../config/extension.js'; import type { ExtensionInstallMetadata } from '@google/gemini-cli-core'; - import { getErrorMessage } from '../../utils/errors.js'; +import { stat } from 'node:fs/promises'; interface InstallArgs { - source?: string; - path?: string; + source: string; ref?: string; autoUpdate?: boolean; } @@ -23,32 +22,34 @@ interface InstallArgs { export async function handleInstall(args: InstallArgs) { try { let installMetadata: ExtensionInstallMetadata; - if (args.source) { - const { source } = args; - if ( - source.startsWith('http://') || - source.startsWith('https://') || - source.startsWith('git@') || - source.startsWith('sso://') - ) { - installMetadata = { - source, - type: 'git', - ref: args.ref, - autoUpdate: args.autoUpdate, - }; - } else { - throw new Error(`The source "${source}" is not a valid URL format.`); - } - } else if (args.path) { + const { source } = args; + if ( + source.startsWith('http://') || + source.startsWith('https://') || + source.startsWith('git@') || + source.startsWith('sso://') + ) { installMetadata = { - source: args.path, - type: 'local', + source, + type: 'git', + ref: args.ref, autoUpdate: args.autoUpdate, }; } else { - // This should not be reached due to the yargs check. - throw new Error('Either --source or --path must be provided.'); + if (args.ref || args.autoUpdate) { + throw new Error( + '--ref and --auto-update are not applicable for local extensions.', + ); + } + try { + await stat(source); + installMetadata = { + source, + type: 'local', + }; + } catch { + throw new Error('Install source not found.'); + } } const name = await installExtension( @@ -63,17 +64,14 @@ export async function handleInstall(args: InstallArgs) { } export const installCommand: CommandModule = { - command: 'install [] [--path] [--ref] [--auto-update]', + command: 'install ', describe: 'Installs an extension from a git repository URL or a local path.', builder: (yargs) => yargs .positional('source', { - describe: 'The github URL of the extension to install.', - type: 'string', - }) - .option('path', { - describe: 'Path to a local extension directory.', + describe: 'The github URL or local path of the extension to install.', type: 'string', + demandOption: true, }) .option('ref', { describe: 'The git ref to install from.', @@ -83,19 +81,15 @@ export const installCommand: CommandModule = { describe: 'Enable auto-update for this extension.', type: 'boolean', }) - .conflicts('source', 'path') - .conflicts('path', 'ref') - .conflicts('path', 'auto-update') .check((argv) => { - if (!argv.source && !argv.path) { - throw new Error('Either source or --path must be provided.'); + if (!argv.source) { + throw new Error('The source argument must be provided.'); } return true; }), handler: async (argv) => { await handleInstall({ - source: argv['source'] as string | undefined, - path: argv['path'] as string | undefined, + source: argv['source'] as string, ref: argv['ref'] as string | undefined, autoUpdate: argv['auto-update'] as boolean | undefined, });