From d3563e2f0eb1f2e6f4e11bd28b04f1ac36bc589b Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Sun, 4 Jan 2026 20:40:21 -0800 Subject: [PATCH] Agent Skills: Add gemini skills CLI management command (#15837) --- packages/cli/src/commands/skills.test.tsx | 56 +++++++ packages/cli/src/commands/skills.tsx | 29 ++++ .../cli/src/commands/skills/disable.test.ts | 115 +++++++++++++++ packages/cli/src/commands/skills/disable.ts | 65 +++++++++ .../cli/src/commands/skills/enable.test.ts | 115 +++++++++++++++ packages/cli/src/commands/skills/enable.ts | 65 +++++++++ packages/cli/src/commands/skills/list.test.ts | 138 ++++++++++++++++++ packages/cli/src/commands/skills/list.ts | 61 ++++++++ packages/cli/src/config/config.ts | 5 + packages/cli/src/config/settings.ts | 1 + 10 files changed, 650 insertions(+) create mode 100644 packages/cli/src/commands/skills.test.tsx create mode 100644 packages/cli/src/commands/skills.tsx create mode 100644 packages/cli/src/commands/skills/disable.test.ts create mode 100644 packages/cli/src/commands/skills/disable.ts create mode 100644 packages/cli/src/commands/skills/enable.test.ts create mode 100644 packages/cli/src/commands/skills/enable.ts create mode 100644 packages/cli/src/commands/skills/list.test.ts create mode 100644 packages/cli/src/commands/skills/list.ts diff --git a/packages/cli/src/commands/skills.test.tsx b/packages/cli/src/commands/skills.test.tsx new file mode 100644 index 0000000000..5a76ab0d95 --- /dev/null +++ b/packages/cli/src/commands/skills.test.tsx @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { skillsCommand } from './skills.js'; + +vi.mock('./skills/list.js', () => ({ listCommand: { command: 'list' } })); +vi.mock('./skills/enable.js', () => ({ + enableCommand: { command: 'enable ' }, +})); +vi.mock('./skills/disable.js', () => ({ + disableCommand: { command: 'disable ' }, +})); + +vi.mock('../gemini.js', () => ({ + initializeOutputListenersAndFlush: vi.fn(), +})); + +describe('skillsCommand', () => { + it('should have correct command and aliases', () => { + expect(skillsCommand.command).toBe('skills '); + expect(skillsCommand.aliases).toEqual(['skill']); + expect(skillsCommand.describe).toBe('Manage agent skills.'); + }); + + it('should register all subcommands in builder', () => { + const mockYargs = { + middleware: vi.fn().mockReturnThis(), + command: vi.fn().mockReturnThis(), + demandCommand: vi.fn().mockReturnThis(), + version: vi.fn().mockReturnThis(), + }; + + // @ts-expect-error - Mocking yargs + skillsCommand.builder(mockYargs); + + expect(mockYargs.middleware).toHaveBeenCalled(); + expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' }); + expect(mockYargs.command).toHaveBeenCalledWith({ + command: 'enable ', + }); + expect(mockYargs.command).toHaveBeenCalledWith({ + command: 'disable ', + }); + expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String)); + expect(mockYargs.version).toHaveBeenCalledWith(false); + }); + + it('should have a handler that does nothing', () => { + // @ts-expect-error - Handler doesn't take arguments in this case + expect(skillsCommand.handler()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/skills.tsx b/packages/cli/src/commands/skills.tsx new file mode 100644 index 0000000000..2178456481 --- /dev/null +++ b/packages/cli/src/commands/skills.tsx @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { listCommand } from './skills/list.js'; +import { enableCommand } from './skills/enable.js'; +import { disableCommand } from './skills/disable.js'; +import { initializeOutputListenersAndFlush } from '../gemini.js'; + +export const skillsCommand: CommandModule = { + command: 'skills ', + aliases: ['skill'], + describe: 'Manage agent skills.', + builder: (yargs) => + yargs + .middleware(() => initializeOutputListenersAndFlush()) + .command(listCommand) + .command(enableCommand) + .command(disableCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/skills/disable.test.ts b/packages/cli/src/commands/skills/disable.test.ts new file mode 100644 index 0000000000..20850c6ecc --- /dev/null +++ b/packages/cli/src/commands/skills/disable.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { format } from 'node:util'; +import { handleDisable, disableCommand } from './disable.js'; +import { + loadSettings, + SettingScope, + type LoadedSettings, + type LoadableSettingScope, +} from '../../config/settings.js'; + +const emitConsoleLog = vi.hoisted(() => vi.fn()); +const debugLogger = vi.hoisted(() => ({ + log: vi.fn((message, ...args) => { + emitConsoleLog('log', format(message, ...args)); + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger, + }; +}); + +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('skills disable command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleDisable', () => { + it('should disable an enabled skill in user scope', async () => { + const mockSettings = { + forScope: vi.fn().mockReturnValue({ + settings: { skills: { disabled: [] } }, + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleDisable({ + name: 'skill1', + scope: SettingScope.User as LoadableSettingScope, + }); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'skills.disabled', + ['skill1'], + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" successfully disabled in scope "User".', + ); + }); + + it('should log a message if the skill is already disabled', async () => { + const mockSettings = { + forScope: vi.fn().mockReturnValue({ + settings: { skills: { disabled: ['skill1'] } }, + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleDisable({ + name: 'skill1', + scope: SettingScope.User as LoadableSettingScope, + }); + + expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" is already disabled in scope "User".', + ); + }); + }); + + describe('disableCommand', () => { + it('should have correct command and describe', () => { + expect(disableCommand.command).toBe('disable '); + expect(disableCommand.describe).toBe('Disables an agent skill.'); + }); + }); +}); diff --git a/packages/cli/src/commands/skills/disable.ts b/packages/cli/src/commands/skills/disable.ts new file mode 100644 index 0000000000..1923d0e989 --- /dev/null +++ b/packages/cli/src/commands/skills/disable.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { + loadSettings, + SettingScope, + type LoadableSettingScope, +} from '../../config/settings.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; + +interface DisableArgs { + name: string; + scope: LoadableSettingScope; +} + +export async function handleDisable(args: DisableArgs) { + const { name, scope } = args; + const workspaceDir = process.cwd(); + const settings = loadSettings(workspaceDir); + + const currentDisabled = + settings.forScope(scope).settings.skills?.disabled || []; + + if (currentDisabled.includes(name)) { + debugLogger.log(`Skill "${name}" is already disabled in scope "${scope}".`); + return; + } + + const newDisabled = [...currentDisabled, name]; + settings.setValue(scope, 'skills.disabled', newDisabled); + debugLogger.log(`Skill "${name}" successfully disabled in scope "${scope}".`); +} + +export const disableCommand: CommandModule = { + command: 'disable ', + describe: 'Disables an agent skill.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'The name of the skill to disable.', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'The scope to disable the skill in (user or project).', + type: 'string', + default: 'user', + choices: ['user', 'project'], + }), + handler: async (argv) => { + const scope: LoadableSettingScope = + argv['scope'] === 'project' ? SettingScope.Workspace : SettingScope.User; + await handleDisable({ + name: argv['name'] as string, + scope, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/skills/enable.test.ts b/packages/cli/src/commands/skills/enable.test.ts new file mode 100644 index 0000000000..a720bb0ca9 --- /dev/null +++ b/packages/cli/src/commands/skills/enable.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { format } from 'node:util'; +import { handleEnable, enableCommand } from './enable.js'; +import { + loadSettings, + SettingScope, + type LoadedSettings, + type LoadableSettingScope, +} from '../../config/settings.js'; + +const emitConsoleLog = vi.hoisted(() => vi.fn()); +const debugLogger = vi.hoisted(() => ({ + log: vi.fn((message, ...args) => { + emitConsoleLog('log', format(message, ...args)); + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger, + }; +}); + +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('skills enable command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleEnable', () => { + it('should enable a disabled skill in user scope', async () => { + const mockSettings = { + forScope: vi.fn().mockReturnValue({ + settings: { skills: { disabled: ['skill1'] } }, + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleEnable({ + name: 'skill1', + scope: SettingScope.User as LoadableSettingScope, + }); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'skills.disabled', + [], + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" successfully enabled in scope "User".', + ); + }); + + it('should log a message if the skill is already enabled', async () => { + const mockSettings = { + forScope: vi.fn().mockReturnValue({ + settings: { skills: { disabled: [] } }, + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleEnable({ + name: 'skill1', + scope: SettingScope.User as LoadableSettingScope, + }); + + expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" is already enabled in scope "User".', + ); + }); + }); + + describe('enableCommand', () => { + it('should have correct command and describe', () => { + expect(enableCommand.command).toBe('enable '); + expect(enableCommand.describe).toBe('Enables an agent skill.'); + }); + }); +}); diff --git a/packages/cli/src/commands/skills/enable.ts b/packages/cli/src/commands/skills/enable.ts new file mode 100644 index 0000000000..7b5e19d88f --- /dev/null +++ b/packages/cli/src/commands/skills/enable.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { + loadSettings, + SettingScope, + type LoadableSettingScope, +} from '../../config/settings.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; + +interface EnableArgs { + name: string; + scope: LoadableSettingScope; +} + +export async function handleEnable(args: EnableArgs) { + const { name, scope } = args; + const workspaceDir = process.cwd(); + const settings = loadSettings(workspaceDir); + + const currentDisabled = + settings.forScope(scope).settings.skills?.disabled || []; + const newDisabled = currentDisabled.filter((d) => d !== name); + + if (currentDisabled.length === newDisabled.length) { + debugLogger.log(`Skill "${name}" is already enabled in scope "${scope}".`); + return; + } + + settings.setValue(scope, 'skills.disabled', newDisabled); + debugLogger.log(`Skill "${name}" successfully enabled in scope "${scope}".`); +} + +export const enableCommand: CommandModule = { + command: 'enable ', + describe: 'Enables an agent skill.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'The name of the skill to enable.', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'The scope to enable the skill in (user or project).', + type: 'string', + default: 'user', + choices: ['user', 'project'], + }), + handler: async (argv) => { + const scope: LoadableSettingScope = + argv['scope'] === 'project' ? SettingScope.Workspace : SettingScope.User; + await handleEnable({ + name: argv['name'] as string, + scope, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/skills/list.test.ts b/packages/cli/src/commands/skills/list.test.ts new file mode 100644 index 0000000000..ce5a5d0cf8 --- /dev/null +++ b/packages/cli/src/commands/skills/list.test.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { format } from 'node:util'; +import { handleList, listCommand } from './list.js'; +import { loadSettings, type LoadedSettings } from '../../config/settings.js'; +import { loadCliConfig } from '../../config/config.js'; +import type { Config } from '@google/gemini-cli-core'; +import chalk from 'chalk'; + +const emitConsoleLog = vi.hoisted(() => vi.fn()); +const debugLogger = vi.hoisted(() => ({ + log: vi.fn((message, ...args) => { + emitConsoleLog('log', format(message, ...args)); + }), + error: vi.fn((message, ...args) => { + emitConsoleLog('error', format(message, ...args)); + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: { + emitConsoleLog, + }, + debugLogger, + }; +}); + +vi.mock('../../config/settings.js'); +vi.mock('../../config/config.js'); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('skills list command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + const mockLoadCliConfig = vi.mocked(loadCliConfig); + + beforeEach(async () => { + vi.clearAllMocks(); + mockLoadSettings.mockReturnValue({ + merged: {}, + } as unknown as LoadedSettings); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleList', () => { + it('should log a message if no skills are discovered', async () => { + const mockConfig = { + initialize: vi.fn().mockResolvedValue(undefined), + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue([]), + }), + }; + mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config); + + await handleList(); + + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'No skills discovered.', + ); + }); + + it('should list all discovered skills', async () => { + const skills = [ + { + name: 'skill1', + description: 'desc1', + disabled: false, + location: '/path/to/skill1', + }, + { + name: 'skill2', + description: 'desc2', + disabled: true, + location: '/path/to/skill2', + }, + ]; + const mockConfig = { + initialize: vi.fn().mockResolvedValue(undefined), + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue(skills), + }), + }; + mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config); + + await handleList(); + + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + chalk.bold('Discovered Agent Skills:'), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining('skill1'), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining(chalk.green('[Enabled]')), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining('skill2'), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining(chalk.red('[Disabled]')), + ); + }); + + it('should throw an error when listing fails', async () => { + mockLoadCliConfig.mockRejectedValue(new Error('List failed')); + + await expect(handleList()).rejects.toThrow('List failed'); + }); + }); + + describe('listCommand', () => { + const command = listCommand; + + it('should have correct command and describe', () => { + expect(command.command).toBe('list'); + expect(command.describe).toBe('Lists discovered agent skills.'); + }); + }); +}); diff --git a/packages/cli/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts new file mode 100644 index 0000000000..29b234df98 --- /dev/null +++ b/packages/cli/src/commands/skills/list.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger } from '@google/gemini-cli-core'; +import { loadSettings } from '../../config/settings.js'; +import { loadCliConfig, type CliArgs } from '../../config/config.js'; +import { exitCli } from '../utils.js'; +import chalk from 'chalk'; + +export async function handleList() { + const workspaceDir = process.cwd(); + const settings = loadSettings(workspaceDir); + + const config = await loadCliConfig( + settings.merged, + 'skills-list-session', + { + debug: false, + } as Partial as CliArgs, + { cwd: workspaceDir }, + ); + + // Initialize to trigger extension loading and skill discovery + await config.initialize(); + + const skillManager = config.getSkillManager(); + const skills = skillManager.getAllSkills(); + + if (skills.length === 0) { + debugLogger.log('No skills discovered.'); + return; + } + + debugLogger.log(chalk.bold('Discovered Agent Skills:')); + debugLogger.log(''); + + for (const skill of skills) { + const status = skill.disabled + ? chalk.red('[Disabled]') + : chalk.green('[Enabled]'); + + debugLogger.log(`${chalk.bold(skill.name)} ${status}`); + debugLogger.log(` Description: ${skill.description}`); + debugLogger.log(` Location: ${skill.location}`); + debugLogger.log(''); + } +} + +export const listCommand: CommandModule = { + command: 'list', + describe: 'Lists discovered agent skills.', + builder: (yargs) => yargs, + handler: async () => { + await handleList(); + await exitCli(); + }, +}; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 8763f95dbf..858a929aff 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -9,6 +9,7 @@ import { hideBin } from 'yargs/helpers'; import process from 'node:process'; import { mcpCommand } from '../commands/mcp.js'; import { extensionsCommand } from '../commands/extensions.js'; +import { skillsCommand } from '../commands/skills.js'; import { hooksCommand } from '../commands/hooks.js'; import { Config, @@ -285,6 +286,10 @@ export async function parseArguments(settings: Settings): Promise { yargsInstance.command(extensionsCommand); } + if (settings?.experimental?.skills ?? false) { + yargsInstance.command(skillsCommand); + } + // Register hooks command if hooks are enabled if (settings?.tools?.enableHooks) { yargsInstance.command(hooksCommand); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 31d5f70733..e0a24cbebf 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -91,6 +91,7 @@ const MIGRATION_MAP: Record = { excludeTools: 'tools.exclude', excludeMCPServers: 'mcp.excluded', excludedProjectEnvVars: 'advanced.excludedEnvVars', + experimentalSkills: 'experimental.skills', extensionManagement: 'experimental.extensionManagement', extensions: 'extensions', fileFiltering: 'context.fileFiltering',