mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-18 01:51:20 -07:00
feat(cli): add install and uninstall commands for skills (#16377)
This commit is contained in:
@@ -8,6 +8,8 @@ import type { CommandModule } from 'yargs';
|
||||
import { listCommand } from './skills/list.js';
|
||||
import { enableCommand } from './skills/enable.js';
|
||||
import { disableCommand } from './skills/disable.js';
|
||||
import { installCommand } from './skills/install.js';
|
||||
import { uninstallCommand } from './skills/uninstall.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
|
||||
export const skillsCommand: CommandModule = {
|
||||
@@ -20,6 +22,8 @@ export const skillsCommand: CommandModule = {
|
||||
.command(listCommand)
|
||||
.command(enableCommand)
|
||||
.command(disableCommand)
|
||||
.command(installCommand)
|
||||
.command(uninstallCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
79
packages/cli/src/commands/skills/install.test.ts
Normal file
79
packages/cli/src/commands/skills/install.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockInstallSkill = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../utils/skillUtils.js', () => ({
|
||||
installSkill: mockInstallSkill,
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: { log: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { handleInstall } from './install.js';
|
||||
|
||||
describe('skill install command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
it('should call installSkill with correct arguments for user scope', async () => {
|
||||
mockInstallSkill.mockResolvedValue([
|
||||
{ name: 'test-skill', location: '/mock/user/skills/test-skill' },
|
||||
]);
|
||||
|
||||
await handleInstall({
|
||||
source: 'https://example.com/repo.git',
|
||||
scope: 'user',
|
||||
});
|
||||
|
||||
expect(mockInstallSkill).toHaveBeenCalledWith(
|
||||
'https://example.com/repo.git',
|
||||
'user',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Successfully installed skill: test-skill'),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('location: /mock/user/skills/test-skill'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call installSkill with correct arguments for workspace scope and subpath', async () => {
|
||||
mockInstallSkill.mockResolvedValue([
|
||||
{ name: 'test-skill', location: '/mock/workspace/skills/test-skill' },
|
||||
]);
|
||||
|
||||
await handleInstall({
|
||||
source: 'https://example.com/repo.git',
|
||||
scope: 'workspace',
|
||||
path: 'my-skills-dir',
|
||||
});
|
||||
|
||||
expect(mockInstallSkill).toHaveBeenCalledWith(
|
||||
'https://example.com/repo.git',
|
||||
'workspace',
|
||||
'my-skills-dir',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockInstallSkill.mockRejectedValue(new Error('Install failed'));
|
||||
|
||||
await handleInstall({ source: '/local/path' });
|
||||
|
||||
expect(debugLogger.error).toHaveBeenCalledWith('Install failed');
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
85
packages/cli/src/commands/skills/install.ts
Normal file
85
packages/cli/src/commands/skills/install.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { installSkill } from '../../utils/skillUtils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
interface InstallArgs {
|
||||
source: string;
|
||||
scope?: 'user' | 'workspace';
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export async function handleInstall(args: InstallArgs) {
|
||||
try {
|
||||
const { source } = args;
|
||||
const scope = args.scope ?? 'user';
|
||||
const subpath = args.path;
|
||||
|
||||
const installedSkills = await installSkill(
|
||||
source,
|
||||
scope,
|
||||
subpath,
|
||||
(msg) => {
|
||||
debugLogger.log(msg);
|
||||
},
|
||||
);
|
||||
|
||||
for (const skill of installedSkills) {
|
||||
debugLogger.log(
|
||||
chalk.green(
|
||||
`Successfully installed skill: ${chalk.bold(skill.name)} (scope: ${scope}, location: ${skill.location})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
await exitCli(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const installCommand: CommandModule = {
|
||||
command: 'install <source>',
|
||||
describe:
|
||||
'Installs an agent skill from a git repository URL or a local path.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('source', {
|
||||
describe:
|
||||
'The git repository URL or local path of the skill to install.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('scope', {
|
||||
describe:
|
||||
'The scope to install the skill into. Defaults to "user" (global).',
|
||||
choices: ['user', 'workspace'],
|
||||
default: 'user',
|
||||
})
|
||||
.option('path', {
|
||||
describe:
|
||||
'Sub-path within the repository to install from (only used for git repository sources).',
|
||||
type: 'string',
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.source) {
|
||||
throw new Error('The source argument must be provided.');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleInstall({
|
||||
source: argv['source'] as string,
|
||||
scope: argv['scope'] as 'user' | 'workspace',
|
||||
path: argv['path'] as string | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
78
packages/cli/src/commands/skills/uninstall.test.ts
Normal file
78
packages/cli/src/commands/skills/uninstall.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockUninstallSkill = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../utils/skillUtils.js', () => ({
|
||||
uninstallSkill: mockUninstallSkill,
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: { log: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { handleUninstall } from './uninstall.js';
|
||||
|
||||
describe('skill uninstall command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
it('should call uninstallSkill with correct arguments for user scope', async () => {
|
||||
mockUninstallSkill.mockResolvedValue({
|
||||
location: '/mock/user/skills/test-skill',
|
||||
});
|
||||
|
||||
await handleUninstall({
|
||||
name: 'test-skill',
|
||||
scope: 'user',
|
||||
});
|
||||
|
||||
expect(mockUninstallSkill).toHaveBeenCalledWith('test-skill', 'user');
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Successfully uninstalled skill: test-skill'),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('location: /mock/user/skills/test-skill'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call uninstallSkill with correct arguments for workspace scope', async () => {
|
||||
mockUninstallSkill.mockResolvedValue({
|
||||
location: '/mock/workspace/skills/test-skill',
|
||||
});
|
||||
|
||||
await handleUninstall({
|
||||
name: 'test-skill',
|
||||
scope: 'workspace',
|
||||
});
|
||||
|
||||
expect(mockUninstallSkill).toHaveBeenCalledWith('test-skill', 'workspace');
|
||||
});
|
||||
|
||||
it('should log an error if skill is not found', async () => {
|
||||
mockUninstallSkill.mockResolvedValue(null);
|
||||
|
||||
await handleUninstall({ name: 'test-skill' });
|
||||
|
||||
expect(debugLogger.error).toHaveBeenCalledWith(
|
||||
'Skill "test-skill" is not installed in the user scope.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockUninstallSkill.mockRejectedValue(new Error('Uninstall failed'));
|
||||
|
||||
await handleUninstall({ name: 'test-skill' });
|
||||
|
||||
expect(debugLogger.error).toHaveBeenCalledWith('Uninstall failed');
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
72
packages/cli/src/commands/skills/uninstall.ts
Normal file
72
packages/cli/src/commands/skills/uninstall.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { uninstallSkill } from '../../utils/skillUtils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
interface UninstallArgs {
|
||||
name: string;
|
||||
scope?: 'user' | 'workspace';
|
||||
}
|
||||
|
||||
export async function handleUninstall(args: UninstallArgs) {
|
||||
try {
|
||||
const { name } = args;
|
||||
const scope = args.scope ?? 'user';
|
||||
|
||||
const result = await uninstallSkill(name, scope);
|
||||
|
||||
if (result) {
|
||||
debugLogger.log(
|
||||
chalk.green(
|
||||
`Successfully uninstalled skill: ${chalk.bold(name)} (scope: ${scope}, location: ${result.location})`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
debugLogger.error(
|
||||
`Skill "${name}" is not installed in the ${scope} scope.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
await exitCli(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const uninstallCommand: CommandModule = {
|
||||
command: 'uninstall <name>',
|
||||
describe: 'Uninstalls an agent skill by name.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'The name of the skill to uninstall.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('scope', {
|
||||
describe:
|
||||
'The scope to uninstall the skill from. Defaults to "user" (global).',
|
||||
choices: ['user', 'workspace'],
|
||||
default: 'user',
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.name) {
|
||||
throw new Error('The skill name must be provided.');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleUninstall({
|
||||
name: argv['name'] as string,
|
||||
scope: argv['scope'] as 'user' | 'workspace',
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user