From de1233b8ca5f8c75b464f5a1e4d6cbac203fae0e Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Tue, 30 Dec 2025 13:35:52 -0800 Subject: [PATCH] Agent Skills: Implement Core Skill Infrastructure & Tiered Discovery (#15698) --- docs/get-started/configuration.md | 12 + package-lock.json | 11 +- packages/cli/src/config/config.ts | 2 + packages/cli/src/config/settingsSchema.ts | 32 +++ .../src/services/BuiltinCommandLoader.test.ts | 11 + .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/ui/commands/skillsCommand.test.ts | 227 +++++++++++++++++ packages/cli/src/ui/commands/skillsCommand.ts | 213 ++++++++++++++++ .../src/ui/components/HistoryItemDisplay.tsx | 7 + .../ui/components/views/SkillsList.test.tsx | 90 +++++++ .../src/ui/components/views/SkillsList.tsx | 85 +++++++ packages/cli/src/ui/types.ts | 14 ++ packages/core/package.json | 2 + packages/core/src/config/config.ts | 25 +- packages/core/src/config/storage.test.ts | 10 + packages/core/src/config/storage.ts | 8 + .../core/src/services/skillManager.test.ts | 237 ++++++++++++++++++ packages/core/src/services/skillManager.ts | 197 +++++++++++++++ schemas/settings.schema.json | 27 ++ 19 files changed, 1209 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/ui/commands/skillsCommand.test.ts create mode 100644 packages/cli/src/ui/commands/skillsCommand.ts create mode 100644 packages/cli/src/ui/components/views/SkillsList.test.tsx create mode 100644 packages/cli/src/ui/components/views/SkillsList.tsx create mode 100644 packages/core/src/services/skillManager.test.ts create mode 100644 packages/core/src/services/skillManager.ts diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 3b0d9a538f..db9161aaf8 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -822,6 +822,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`experimental.skills`** (boolean): + - **Description:** Enable Agent Skills (experimental). + - **Default:** `false` + - **Requires restart:** Yes + - **`experimental.codebaseInvestigatorSettings.enabled`** (boolean): - **Description:** Enable the Codebase Investigator agent. - **Default:** `true` @@ -854,6 +859,13 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +#### `skills` + +- **`skills.disabled`** (array): + - **Description:** List of disabled skills. + - **Default:** `[]` + - **Requires restart:** Yes + #### `hooks` - **`hooks.disabled`** (array): diff --git a/package-lock.json b/package-lock.json index 30676d4cf2..ccd58eefdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4262,6 +4262,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6095,7 +6102,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { @@ -11827,7 +11833,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -18921,6 +18926,7 @@ "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", + "js-yaml": "^4.1.1", "marked": "^15.0.12", "mime": "4.0.7", "mnemonist": "^0.40.3", @@ -18940,6 +18946,7 @@ "@google/gemini-cli-test-utils": "file:../test-utils", "@types/diff": "^7.0.2", "@types/fast-levenshtein": "^0.0.4", + "@types/js-yaml": "^4.0.9", "@types/minimatch": "^5.1.2", "@types/picomatch": "^4.0.1", "msw": "^2.3.4", diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 52b718d938..28e1d8a629 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -667,6 +667,8 @@ export async function loadCliConfig( extensionLoader: extensionManager, enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, + skillsSupport: settings.experimental?.skills, + disabledSkills: settings.skills?.disabled, experimentalJitContext: settings.experimental?.jitContext, noBrowser: !!process.env['NO_BROWSER'], summarizeToolOutput: settings.model?.summarizeToolOutput, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 465f1087d3..7672b9e1c4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1383,6 +1383,15 @@ const SETTINGS_SCHEMA = { description: 'Enable Just-In-Time (JIT) context loading.', showInDialog: false, }, + skills: { + type: 'boolean', + label: 'Agent Skills', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enable Agent Skills (experimental).', + showInDialog: false, + }, codebaseInvestigatorSettings: { type: 'object', label: 'Codebase Investigator Settings', @@ -1501,6 +1510,29 @@ const SETTINGS_SCHEMA = { }, }, + skills: { + type: 'object', + label: 'Skills', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: 'Settings for agent skills.', + showInDialog: true, + properties: { + disabled: { + type: 'array', + label: 'Disabled Skills', + category: 'Advanced', + requiresRestart: true, + default: [] as string[], + description: 'List of disabled skills.', + showInDialog: false, + items: { type: 'string' }, + mergeStrategy: MergeStrategy.UNION, + }, + }, + }, + hooks: { type: 'object', label: 'Hooks', diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index f030a60215..4d8fe6773d 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -79,6 +79,9 @@ vi.mock('../ui/commands/resumeCommand.js', () => ({ resumeCommand: {} })); vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} })); vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} })); vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} })); +vi.mock('../ui/commands/skillsCommand.js', () => ({ + skillsCommand: { name: 'skills' }, +})); vi.mock('../ui/commands/mcpCommand.js', () => ({ mcpCommand: { name: 'mcp', @@ -98,6 +101,10 @@ describe('BuiltinCommandLoader', () => { getFolderTrust: vi.fn().mockReturnValue(true), getEnableExtensionReloading: () => false, getEnableHooks: () => false, + isSkillsSupportEnabled: vi.fn().mockReturnValue(false), + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue([]), + }), } as unknown as Config; restoreCommandMock.mockReturnValue({ @@ -190,6 +197,10 @@ describe('BuiltinCommandLoader profile', () => { getCheckpointingEnabled: () => false, getEnableExtensionReloading: () => false, getEnableHooks: () => false, + isSkillsSupportEnabled: vi.fn().mockReturnValue(false), + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue([]), + }), } as unknown as Config; }); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 9c54afe54b..6978322bbf 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -38,6 +38,7 @@ import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; +import { skillsCommand } from '../ui/commands/skillsCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; @@ -89,6 +90,7 @@ export class BuiltinCommandLoader implements ICommandLoader { statsCommand, themeCommand, toolsCommand, + ...(this.config?.isSkillsSupportEnabled() ? [skillsCommand] : []), settingsCommand, vimCommand, setupGithubCommand, diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts new file mode 100644 index 0000000000..9d3b168b48 --- /dev/null +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { skillsCommand } from './skillsCommand.js'; +import { MessageType } from '../types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { CommandContext } from './types.js'; +import type { Config } from '@google/gemini-cli-core'; +import { SettingScope, type LoadedSettings } from '../../config/settings.js'; + +describe('skillsCommand', () => { + let context: CommandContext; + + beforeEach(() => { + const skills = [ + { + name: 'skill1', + description: 'desc1', + location: '/loc1', + body: 'body1', + }, + { + name: 'skill2', + description: 'desc2', + location: '/loc2', + body: 'body2', + }, + ]; + context = createMockCommandContext({ + services: { + config: { + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue(skills), + getSkill: vi + .fn() + .mockImplementation( + (name: string) => skills.find((s) => s.name === name) ?? null, + ), + }), + } as unknown as Config, + settings: { + merged: { skills: { disabled: [] } }, + workspace: { path: '/workspace' }, + setValue: vi.fn(), + } as unknown as LoadedSettings, + }, + }); + }); + + it('should add a SKILLS_LIST item to UI with descriptions by default', async () => { + await skillsCommand.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.SKILLS_LIST, + skills: [ + { name: 'skill1', description: 'desc1' }, + { name: 'skill2', description: 'desc2' }, + ], + showDescriptions: true, + }), + expect.any(Number), + ); + }); + + it('should list skills when "list" subcommand is used', async () => { + const listCmd = skillsCommand.subCommands!.find((s) => s.name === 'list')!; + await listCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.SKILLS_LIST, + skills: [ + { name: 'skill1', description: 'desc1' }, + { name: 'skill2', description: 'desc2' }, + ], + showDescriptions: true, + }), + expect.any(Number), + ); + }); + + it('should disable descriptions if "nodesc" arg is provided to list', async () => { + const listCmd = skillsCommand.subCommands!.find((s) => s.name === 'list')!; + await listCmd.action!(context, 'nodesc'); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + showDescriptions: false, + }), + expect.any(Number), + ); + }); + + describe('disable/enable', () => { + beforeEach(() => { + context.services.settings.merged.skills = { disabled: [] }; + ( + context.services.settings as unknown as { workspace: { path: string } } + ).workspace = { + path: '/workspace', + }; + }); + + it('should disable a skill', async () => { + const disableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'disable', + )!; + await disableCmd.action!(context, 'skill1'); + + expect(context.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'skills.disabled', + ['skill1'], + ); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Skill "skill1" disabled'), + }), + expect.any(Number), + ); + }); + + it('should enable a skill', async () => { + const enableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'enable', + )!; + context.services.settings.merged.skills = { disabled: ['skill1'] }; + await enableCmd.action!(context, 'skill1'); + + expect(context.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'skills.disabled', + [], + ); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Skill "skill1" enabled'), + }), + expect.any(Number), + ); + }); + + it('should show error if skill not found during disable', async () => { + const disableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'disable', + )!; + await disableCmd.action!(context, 'non-existent'); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Skill "non-existent" not found.', + }), + expect.any(Number), + ); + }); + }); + + describe('completions', () => { + it('should provide completions for disable (only enabled skills)', async () => { + const disableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'disable', + )!; + const skillManager = context.services.config!.getSkillManager(); + const mockSkills = [ + { + name: 'skill1', + description: 'desc1', + disabled: false, + location: '/loc1', + body: 'body1', + }, + { + name: 'skill2', + description: 'desc2', + disabled: true, + location: '/loc2', + body: 'body2', + }, + ]; + vi.mocked(skillManager.getAllSkills).mockReturnValue(mockSkills); + vi.mocked(skillManager.getSkill).mockImplementation( + (name: string) => mockSkills.find((s) => s.name === name) ?? null, + ); + + const completions = await disableCmd.completion!(context, 'sk'); + expect(completions).toEqual(['skill1']); + }); + + it('should provide completions for enable (only disabled skills)', async () => { + const enableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'enable', + )!; + const skillManager = context.services.config!.getSkillManager(); + const mockSkills = [ + { + name: 'skill1', + description: 'desc1', + disabled: false, + location: '/loc1', + body: 'body1', + }, + { + name: 'skill2', + description: 'desc2', + disabled: true, + location: '/loc2', + body: 'body2', + }, + ]; + vi.mocked(skillManager.getAllSkills).mockReturnValue(mockSkills); + vi.mocked(skillManager.getSkill).mockImplementation( + (name: string) => mockSkills.find((s) => s.name === name) ?? null, + ); + + const completions = await enableCmd.completion!(context, 'sk'); + expect(completions).toEqual(['skill2']); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts new file mode 100644 index 0000000000..42fa0afc11 --- /dev/null +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type CommandContext, + type SlashCommand, + type SlashCommandActionReturn, + CommandKind, +} from './types.js'; +import { MessageType, type HistoryItemSkillsList } from '../types.js'; +import { SettingScope } from '../../config/settings.js'; + +async function listAction( + context: CommandContext, + args: string, +): Promise { + const subCommand = args.trim(); + + // Default to SHOWING descriptions. The user can hide them with 'nodesc'. + let useShowDescriptions = true; + if (subCommand === 'nodesc') { + useShowDescriptions = false; + } + + const skillManager = context.services.config?.getSkillManager(); + if (!skillManager) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: 'Could not retrieve skill manager.', + }, + Date.now(), + ); + return; + } + + const skills = skillManager.getAllSkills(); + + const skillsListItem: HistoryItemSkillsList = { + type: MessageType.SKILLS_LIST, + skills: skills.map((skill) => ({ + name: skill.name, + description: skill.description, + disabled: skill.disabled, + })), + showDescriptions: useShowDescriptions, + }; + + context.ui.addItem(skillsListItem, Date.now()); +} + +async function disableAction( + context: CommandContext, + args: string, +): Promise { + const skillName = args.trim(); + if (!skillName) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: 'Please provide a skill name to disable.', + }, + Date.now(), + ); + return; + } + const skillManager = context.services.config?.getSkillManager(); + const skill = skillManager?.getSkill(skillName); + if (!skill) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Skill "${skillName}" not found.`, + }, + Date.now(), + ); + return; + } + + const currentDisabled = + context.services.settings.merged.skills?.disabled ?? []; + if (currentDisabled.includes(skillName)) { + context.ui.addItem( + { + type: MessageType.INFO, + text: `Skill "${skillName}" is already disabled.`, + }, + Date.now(), + ); + return; + } + + const newDisabled = [...currentDisabled, skillName]; + const scope = context.services.settings.workspace.path + ? SettingScope.Workspace + : SettingScope.User; + + context.services.settings.setValue(scope, 'skills.disabled', newDisabled); + context.ui.addItem( + { + type: MessageType.INFO, + text: `Skill "${skillName}" disabled in ${scope} settings. Restart required to take effect.`, + }, + Date.now(), + ); +} + +async function enableAction( + context: CommandContext, + args: string, +): Promise { + const skillName = args.trim(); + if (!skillName) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: 'Please provide a skill name to enable.', + }, + Date.now(), + ); + return; + } + + const currentDisabled = + context.services.settings.merged.skills?.disabled ?? []; + if (!currentDisabled.includes(skillName)) { + context.ui.addItem( + { + type: MessageType.INFO, + text: `Skill "${skillName}" is not disabled.`, + }, + Date.now(), + ); + return; + } + + const newDisabled = currentDisabled.filter((name) => name !== skillName); + const scope = context.services.settings.workspace.path + ? SettingScope.Workspace + : SettingScope.User; + + context.services.settings.setValue(scope, 'skills.disabled', newDisabled); + context.ui.addItem( + { + type: MessageType.INFO, + text: `Skill "${skillName}" enabled in ${scope} settings. Restart required to take effect.`, + }, + Date.now(), + ); +} + +function disableCompletion( + context: CommandContext, + partialArg: string, +): string[] { + const skillManager = context.services.config?.getSkillManager(); + if (!skillManager) { + return []; + } + return skillManager + .getAllSkills() + .filter((s) => !s.disabled && s.name.startsWith(partialArg)) + .map((s) => s.name); +} + +function enableCompletion( + context: CommandContext, + partialArg: string, +): string[] { + const skillManager = context.services.config?.getSkillManager(); + if (!skillManager) { + return []; + } + return skillManager + .getAllSkills() + .filter((s) => s.disabled && s.name.startsWith(partialArg)) + .map((s) => s.name); +} + +export const skillsCommand: SlashCommand = { + name: 'skills', + description: + 'List, enable, or disable Gemini CLI agent skills. Usage: /skills [list | disable | enable ]', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [ + { + name: 'list', + description: 'List available agent skills. Usage: /skills list [nodesc]', + kind: CommandKind.BUILT_IN, + action: listAction, + }, + { + name: 'disable', + description: 'Disable a skill by name. Usage: /skills disable ', + kind: CommandKind.BUILT_IN, + action: disableAction, + completion: disableCompletion, + }, + { + name: 'enable', + description: + 'Enable a disabled skill by name. Usage: /skills enable ', + kind: CommandKind.BUILT_IN, + action: enableAction, + completion: enableCompletion, + }, + ], + action: listAction, +}; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index ae0a9e1a2f..5a7f769402 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -28,6 +28,7 @@ import type { SlashCommand } from '../commands/types.js'; import { ExtensionsList } from './views/ExtensionsList.js'; import { getMCPServerStatus } from '@google/gemini-cli-core'; import { ToolsList } from './views/ToolsList.js'; +import { SkillsList } from './views/SkillsList.js'; import { McpStatus } from './views/McpStatus.js'; import { ChatList } from './views/ChatList.js'; import { HooksList } from './views/HooksList.js'; @@ -153,6 +154,12 @@ export const HistoryItemDisplay: React.FC = ({ showDescriptions={itemForDisplay.showDescriptions} /> )} + {itemForDisplay.type === 'skills_list' && ( + + )} {itemForDisplay.type === 'mcp_status' && ( )} diff --git a/packages/cli/src/ui/components/views/SkillsList.test.tsx b/packages/cli/src/ui/components/views/SkillsList.test.tsx new file mode 100644 index 0000000000..9d11ee241b --- /dev/null +++ b/packages/cli/src/ui/components/views/SkillsList.test.tsx @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { describe, it, expect } from 'vitest'; +import { SkillsList } from './SkillsList.js'; +import { type SkillDefinition } from '../../types.js'; + +describe('SkillsList Component', () => { + const mockSkills: SkillDefinition[] = [ + { name: 'skill1', description: 'description 1', disabled: false }, + { name: 'skill2', description: 'description 2', disabled: true }, + { name: 'skill3', description: 'description 3', disabled: false }, + ]; + + it('should render enabled and disabled skills separately', () => { + const { lastFrame, unmount } = render( + , + ); + const output = lastFrame(); + + expect(output).toContain('Available Agent Skills:'); + expect(output).toContain('skill1'); + expect(output).toContain('description 1'); + expect(output).toContain('skill3'); + expect(output).toContain('description 3'); + + expect(output).toContain('Disabled Skills:'); + expect(output).toContain('skill2'); + expect(output).toContain('description 2'); + + unmount(); + }); + + it('should not render descriptions when showDescriptions is false', () => { + const { lastFrame, unmount } = render( + , + ); + const output = lastFrame(); + + expect(output).toContain('skill1'); + expect(output).not.toContain('description 1'); + expect(output).toContain('skill2'); + expect(output).not.toContain('description 2'); + expect(output).toContain('skill3'); + expect(output).not.toContain('description 3'); + + unmount(); + }); + + it('should render "No skills available" when skills list is empty', () => { + const { lastFrame, unmount } = render( + , + ); + const output = lastFrame(); + + expect(output).toContain('No skills available'); + + unmount(); + }); + + it('should only render Available Agent Skills section when all skills are enabled', () => { + const enabledOnly = mockSkills.filter((s) => !s.disabled); + const { lastFrame, unmount } = render( + , + ); + const output = lastFrame(); + + expect(output).toContain('Available Agent Skills:'); + expect(output).not.toContain('Disabled Skills:'); + + unmount(); + }); + + it('should only render Disabled Skills section when all skills are disabled', () => { + const disabledOnly = mockSkills.filter((s) => s.disabled); + const { lastFrame, unmount } = render( + , + ); + const output = lastFrame(); + + expect(output).not.toContain('Available Agent Skills:'); + expect(output).toContain('Disabled Skills:'); + + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/views/SkillsList.tsx b/packages/cli/src/ui/components/views/SkillsList.tsx new file mode 100644 index 0000000000..ebb3c8519b --- /dev/null +++ b/packages/cli/src/ui/components/views/SkillsList.tsx @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { type SkillDefinition } from '../../types.js'; + +interface SkillsListProps { + skills: readonly SkillDefinition[]; + showDescriptions: boolean; +} + +export const SkillsList: React.FC = ({ + skills, + showDescriptions, +}) => { + const enabledSkills = skills + .filter((s) => !s.disabled) + .sort((a, b) => a.name.localeCompare(b.name)); + + const disabledSkills = skills + .filter((s) => s.disabled) + .sort((a, b) => a.name.localeCompare(b.name)); + + const renderSkill = (skill: SkillDefinition) => ( + + {' '}- + + + {skill.name} + + {showDescriptions && skill.description && ( + + + {skill.description} + + + )} + + + ); + + return ( + + {enabledSkills.length > 0 && ( + + + Available Agent Skills: + + + {enabledSkills.map(renderSkill)} + + )} + + {enabledSkills.length > 0 && disabledSkills.length > 0 && ( + + {'-'.repeat(20)} + + )} + + {disabledSkills.length > 0 && ( + + + Disabled Skills: + + + {disabledSkills.map(renderSkill)} + + )} + + {skills.length === 0 && ( + No skills available + )} + + ); +}; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 32bd291e5d..ede5ab5b84 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -206,6 +206,18 @@ export type HistoryItemToolsList = HistoryItemBase & { showDescriptions: boolean; }; +export interface SkillDefinition { + name: string; + description: string; + disabled?: boolean; +} + +export type HistoryItemSkillsList = HistoryItemBase & { + type: 'skills_list'; + skills: SkillDefinition[]; + showDescriptions: boolean; +}; + // JSON-friendly types for using as a simple data model showing info about an // MCP Server. export interface JsonMcpTool { @@ -284,6 +296,7 @@ export type HistoryItemWithoutId = | HistoryItemCompression | HistoryItemExtensionsList | HistoryItemToolsList + | HistoryItemSkillsList | HistoryItemMcpStatus | HistoryItemChatList | HistoryItemHooksList; @@ -306,6 +319,7 @@ export enum MessageType { COMPRESSION = 'compression', EXTENSIONS_LIST = 'extensions_list', TOOLS_LIST = 'tools_list', + SKILLS_LIST = 'skills_list', MCP_STATUS = 'mcp_status', CHAT_LIST = 'chat_list', HOOKS_LIST = 'hooks_list', diff --git a/packages/core/package.json b/packages/core/package.json index eabb08c997..e69c6545fa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -53,6 +53,7 @@ "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", + "js-yaml": "^4.1.1", "marked": "^15.0.12", "mime": "4.0.7", "mnemonist": "^0.40.3", @@ -81,6 +82,7 @@ "@google/gemini-cli-test-utils": "file:../test-utils", "@types/diff": "^7.0.2", "@types/fast-levenshtein": "^0.0.4", + "@types/js-yaml": "^4.0.9", "@types/minimatch": "^5.1.2", "@types/picomatch": "^4.0.1", "msw": "^2.3.4", diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e1aeed6eee..896a4693e5 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -94,6 +94,7 @@ import { DELEGATE_TO_AGENT_TOOL_NAME } from '../tools/tool-names.js'; import { getExperiments } from '../code_assist/experiments/experiments.js'; import { ExperimentFlags } from '../code_assist/experiments/flagNames.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { SkillManager } from '../services/skillManager.js'; import { startupProfiler } from '../telemetry/startupProfiler.js'; import { ApprovalMode } from '../policy/types.js'; @@ -348,6 +349,8 @@ export interface ConfigParameters { }; previewFeatures?: boolean; enableAgents?: boolean; + skillsSupport?: boolean; + disabledSkills?: string[]; experimentalJitContext?: boolean; onModelChange?: (model: string) => void; } @@ -363,6 +366,7 @@ export class Config { private promptRegistry!: PromptRegistry; private resourceRegistry!: ResourceRegistry; private agentRegistry!: AgentRegistry; + private skillManager!: SkillManager; private sessionId: string; private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; @@ -475,6 +479,8 @@ export class Config { private readonly onModelChange: ((model: string) => void) | undefined; private readonly enableAgents: boolean; + private readonly skillsSupport: boolean; + private readonly disabledSkills: string[]; private readonly experimentalJitContext: boolean; private contextManager?: ContextManager; @@ -542,9 +548,11 @@ export class Config { this.model = params.model; this._activeModel = params.model; this.enableAgents = params.enableAgents ?? false; - this.experimentalJitContext = params.experimentalJitContext ?? false; + this.skillsSupport = params.skillsSupport ?? false; + this.disabledSkills = params.disabledSkills ?? []; this.modelAvailabilityService = new ModelAvailabilityService(); this.previewFeatures = params.previewFeatures ?? undefined; + this.experimentalJitContext = params.experimentalJitContext ?? false; this.maxSessionTurns = params.maxSessionTurns ?? -1; this.experimentalZedIntegration = params.experimentalZedIntegration ?? false; @@ -624,6 +632,7 @@ export class Config { params.approvalMode ?? params.policyEngineConfig?.approvalMode, }); this.messageBus = new MessageBus(this.policyEngine, this.debugMode); + this.skillManager = new SkillManager(); this.outputSettings = { format: params.output?.format ?? OutputFormat.TEXT, }; @@ -721,6 +730,12 @@ export class Config { ]); initMcpHandle?.end(); + // Discover skills if enabled + if (this.skillsSupport) { + await this.getSkillManager().discoverSkills(this.storage); + this.getSkillManager().setDisabledSkills(this.disabledSkills); + } + // Initialize hook system if enabled if (this.enableHooks) { this.hookSystem = new HookSystem(this); @@ -973,6 +988,10 @@ export class Config { return this.promptRegistry; } + getSkillManager(): SkillManager { + return this.skillManager; + } + getResourceRegistry(): ResourceRegistry { return this.resourceRegistry; } @@ -1478,6 +1497,10 @@ export class Config { ); } + isSkillsSupportEnabled(): boolean { + return this.skillsSupport; + } + isInteractive(): boolean { return this.interactive; } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index fedf2f753f..342ae3866e 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -45,6 +45,16 @@ describe('Storage – additional helpers', () => { expect(storage.getProjectCommandsDir()).toBe(expected); }); + it('getUserSkillsDir returns ~/.gemini/skills', () => { + const expected = path.join(os.homedir(), GEMINI_DIR, 'skills'); + expect(Storage.getUserSkillsDir()).toBe(expected); + }); + + it('getProjectSkillsDir returns project/.gemini/skills', () => { + const expected = path.join(projectRoot, GEMINI_DIR, 'skills'); + expect(storage.getProjectSkillsDir()).toBe(expected); + }); + it('getUserAgentsDir returns ~/.gemini/agents', () => { const expected = path.join(os.homedir(), GEMINI_DIR, 'agents'); expect(Storage.getUserAgentsDir()).toBe(expected); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index d5c32ae8db..7da4aa2a56 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -50,6 +50,10 @@ export class Storage { return path.join(Storage.getGlobalGeminiDir(), 'commands'); } + static getUserSkillsDir(): string { + return path.join(Storage.getGlobalGeminiDir(), 'skills'); + } + static getGlobalMemoryFilePath(): string { return path.join(Storage.getGlobalGeminiDir(), 'memory.md'); } @@ -127,6 +131,10 @@ export class Storage { return path.join(this.getGeminiDir(), 'commands'); } + getProjectSkillsDir(): string { + return path.join(this.getGeminiDir(), 'skills'); + } + getProjectAgentsDir(): string { return path.join(this.getGeminiDir(), 'agents'); } diff --git a/packages/core/src/services/skillManager.test.ts b/packages/core/src/services/skillManager.test.ts new file mode 100644 index 0000000000..a7118605ca --- /dev/null +++ b/packages/core/src/services/skillManager.test.ts @@ -0,0 +1,237 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { SkillManager } from './skillManager.js'; +import { Storage } from '../config/storage.js'; + +describe('SkillManager', () => { + let testRootDir: string; + + beforeEach(async () => { + testRootDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'skill-manager-test-'), + ); + }); + + afterEach(async () => { + await fs.rm(testRootDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('should discover skills with valid SKILL.md and frontmatter', async () => { + const skillDir = path.join(testRootDir, 'my-skill'); + await fs.mkdir(skillDir, { recursive: true }); + const skillFile = path.join(skillDir, 'SKILL.md'); + await fs.writeFile( + skillFile, + `--- +name: my-skill +description: A test skill +--- +# Instructions +Do something. +`, + ); + + const service = new SkillManager(); + const skills = await service.discoverSkillsInternal([testRootDir]); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('my-skill'); + expect(skills[0].description).toBe('A test skill'); + expect(skills[0].location).toBe(skillFile); + expect(skills[0].body).toBe('# Instructions\nDo something.'); + }); + + it('should ignore directories without SKILL.md', async () => { + const notASkillDir = path.join(testRootDir, 'not-a-skill'); + await fs.mkdir(notASkillDir, { recursive: true }); + + const service = new SkillManager(); + const skills = await service.discoverSkillsInternal([testRootDir]); + + expect(skills).toHaveLength(0); + }); + + it('should ignore SKILL.md without valid frontmatter', async () => { + const skillDir = path.join(testRootDir, 'invalid-skill'); + await fs.mkdir(skillDir, { recursive: true }); + const skillFile = path.join(skillDir, 'SKILL.md'); + await fs.writeFile(skillFile, '# No frontmatter here'); + + const service = new SkillManager(); + const skills = await service.discoverSkillsInternal([testRootDir]); + + expect(skills).toHaveLength(0); + }); + + it('should ignore SKILL.md with missing required frontmatter fields', async () => { + const skillDir = path.join(testRootDir, 'missing-fields'); + await fs.mkdir(skillDir, { recursive: true }); + const skillFile = path.join(skillDir, 'SKILL.md'); + await fs.writeFile( + skillFile, + `--- +name: missing-fields +--- +`, + ); + + const service = new SkillManager(); + const skills = await service.discoverSkillsInternal([testRootDir]); + + expect(skills).toHaveLength(0); + }); + + it('should handle multiple search paths', async () => { + const path1 = path.join(testRootDir, 'path1'); + const path2 = path.join(testRootDir, 'path2'); + await fs.mkdir(path1, { recursive: true }); + await fs.mkdir(path2, { recursive: true }); + + const skill1Dir = path.join(path1, 'skill1'); + await fs.mkdir(skill1Dir, { recursive: true }); + await fs.writeFile( + path.join(skill1Dir, 'SKILL.md'), + `--- +name: skill1 +description: Skill 1 +--- +`, + ); + + const skill2Dir = path.join(path2, 'skill2'); + await fs.mkdir(skill2Dir, { recursive: true }); + await fs.writeFile( + path.join(skill2Dir, 'SKILL.md'), + `--- +name: skill2 +description: Skill 2 +--- +`, + ); + + const service = new SkillManager(); + const skills = await service.discoverSkillsInternal([path1, path2]); + + expect(skills).toHaveLength(2); + expect(skills.map((s) => s.name).sort()).toEqual(['skill1', 'skill2']); + }); + + it('should deduplicate skills by name (last wins)', async () => { + const path1 = path.join(testRootDir, 'path1'); + const path2 = path.join(testRootDir, 'path2'); + await fs.mkdir(path1, { recursive: true }); + await fs.mkdir(path2, { recursive: true }); + + await fs.mkdir(path.join(path1, 'skill'), { recursive: true }); + await fs.writeFile( + path.join(path1, 'skill', 'SKILL.md'), + `--- +name: same-name +description: First +--- +`, + ); + + await fs.mkdir(path.join(path2, 'skill'), { recursive: true }); + await fs.writeFile( + path.join(path2, 'skill', 'SKILL.md'), + `--- +name: same-name +description: Second +--- +`, + ); + + const service = new SkillManager(); + // In our tiered discovery logic, we call discoverSkillsInternal for each tier + // and then add them with precedence. + const skills1 = await service.discoverSkillsInternal([path1]); + service['addSkillsWithPrecedence'](skills1); + const skills2 = await service.discoverSkillsInternal([path2]); + service['addSkillsWithPrecedence'](skills2); + + const skills = service.getSkills(); + expect(skills).toHaveLength(1); + expect(skills[0].description).toBe('Second'); + }); + + it('should discover skills from Storage with project precedence', async () => { + const userDir = path.join(testRootDir, 'user'); + const projectDir = path.join(testRootDir, 'project'); + await fs.mkdir(path.join(userDir, 'skill-a'), { recursive: true }); + await fs.mkdir(path.join(projectDir, 'skill-a'), { recursive: true }); + + await fs.writeFile( + path.join(userDir, 'skill-a', 'SKILL.md'), + `--- +name: skill-a +description: user-desc +--- +`, + ); + await fs.writeFile( + path.join(projectDir, 'skill-a', 'SKILL.md'), + `--- +name: skill-a +description: project-desc +--- +`, + ); + + vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir); + const storage = new Storage('/dummy'); + vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectDir); + + const service = new SkillManager(); + await service.discoverSkills(storage); + + const skills = service.getSkills(); + expect(skills).toHaveLength(1); + expect(skills[0].description).toBe('project-desc'); + }); + + it('should filter disabled skills in getSkills but not in getAllSkills', async () => { + const skill1Dir = path.join(testRootDir, 'skill1'); + const skill2Dir = path.join(testRootDir, 'skill2'); + await fs.mkdir(skill1Dir, { recursive: true }); + await fs.mkdir(skill2Dir, { recursive: true }); + + await fs.writeFile( + path.join(skill1Dir, 'SKILL.md'), + `--- +name: skill1 +description: desc1 +--- +`, + ); + await fs.writeFile( + path.join(skill2Dir, 'SKILL.md'), + `--- +name: skill2 +description: desc2 +--- +`, + ); + + const service = new SkillManager(); + const discovered = await service.discoverSkillsInternal([testRootDir]); + service['addSkillsWithPrecedence'](discovered); + service.setDisabledSkills(['skill1']); + + expect(service.getSkills()).toHaveLength(1); + expect(service.getSkills()[0].name).toBe('skill2'); + expect(service.getAllSkills()).toHaveLength(2); + expect( + service.getAllSkills().find((s) => s.name === 'skill1')?.disabled, + ).toBe(true); + }); +}); diff --git a/packages/core/src/services/skillManager.ts b/packages/core/src/services/skillManager.ts new file mode 100644 index 0000000000..846f705548 --- /dev/null +++ b/packages/core/src/services/skillManager.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { glob } from 'glob'; +import yaml from 'js-yaml'; +import { debugLogger } from '../utils/debugLogger.js'; +import { Storage } from '../config/storage.js'; + +export interface SkillMetadata { + name: string; + description: string; + location: string; + body: string; + disabled?: boolean; +} + +const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)/; + +export class SkillManager { + private skills: SkillMetadata[] = []; + private activeSkillNames: Set = new Set(); + + /** + * Clears all discovered skills. + */ + clearSkills(): void { + this.skills = []; + } + + /** + * Discovers skills from standard user and project locations. + * Project skills take precedence over user skills. + */ + async discoverSkills(storage: Storage): Promise { + this.clearSkills(); + + // User skills first + const userPaths = [Storage.getUserSkillsDir()]; + const userSkills = await this.discoverSkillsInternal(userPaths); + this.addSkillsWithPrecedence(userSkills); + + // Project skills second (overwrites user skills with same name) + const projectPaths = [storage.getProjectSkillsDir()]; + const projectSkills = await this.discoverSkillsInternal(projectPaths); + this.addSkillsWithPrecedence(projectSkills); + } + + private addSkillsWithPrecedence(newSkills: SkillMetadata[]): void { + const skillMap = new Map(); + for (const skill of [...this.skills, ...newSkills]) { + skillMap.set(skill.name, skill); + } + this.skills = Array.from(skillMap.values()); + } + + /** + * Discovered skills in the provided paths and adds them to the manager. + * Internal helper for tiered discovery. + */ + async discoverSkillsInternal(paths: string[]): Promise { + const discoveredSkills: SkillMetadata[] = []; + const seenLocations = new Set(this.skills.map((s) => s.location)); + + for (const searchPath of paths) { + try { + const absoluteSearchPath = path.resolve(searchPath); + debugLogger.debug(`Discovering skills in: ${absoluteSearchPath}`); + + const stats = await fs.stat(absoluteSearchPath).catch(() => null); + if (!stats || !stats.isDirectory()) { + debugLogger.debug( + `Search path is not a directory: ${absoluteSearchPath}`, + ); + continue; + } + + const skillFiles = await glob('*/SKILL.md', { + cwd: absoluteSearchPath, + absolute: true, + nodir: true, + }); + + debugLogger.debug( + `Found ${skillFiles.length} potential skill files in ${absoluteSearchPath}`, + ); + + for (const skillFile of skillFiles) { + if (seenLocations.has(skillFile)) { + continue; + } + + const metadata = await this.parseSkillFile(skillFile); + if (metadata) { + debugLogger.debug( + `Discovered skill: ${metadata.name} at ${skillFile}`, + ); + discoveredSkills.push(metadata); + seenLocations.add(skillFile); + } + } + } catch (error) { + debugLogger.log(`Error discovering skills in ${searchPath}:`, error); + } + } + + return discoveredSkills; + } + + /** + * Returns the list of enabled discovered skills. + */ + getSkills(): SkillMetadata[] { + return this.skills.filter((s) => !s.disabled); + } + + /** + * Returns all discovered skills, including disabled ones. + */ + getAllSkills(): SkillMetadata[] { + return this.skills; + } + + /** + * Filters discovered skills by name. + */ + filterSkills(predicate: (skill: SkillMetadata) => boolean): void { + this.skills = this.skills.filter(predicate); + } + + /** + * Sets the list of disabled skill names. + */ + setDisabledSkills(disabledNames: string[]): void { + for (const skill of this.skills) { + skill.disabled = disabledNames.includes(skill.name); + } + } + + /** + * Reads the full content (metadata + body) of a skill by name. + */ + getSkill(name: string): SkillMetadata | null { + return this.skills.find((s) => s.name === name) ?? null; + } + + /** + * Activates a skill by name. + */ + activateSkill(name: string): void { + this.activeSkillNames.add(name); + } + + /** + * Checks if a skill is active. + */ + isSkillActive(name: string): boolean { + return this.activeSkillNames.has(name); + } + + private async parseSkillFile( + filePath: string, + ): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const match = content.match(FRONTMATTER_REGEX); + if (!match) { + return null; + } + + // Use yaml.load() which is safe in js-yaml v4. + const frontmatter = yaml.load(match[1]); + if (!frontmatter || typeof frontmatter !== 'object') { + return null; + } + + const { name, description } = frontmatter as Record; + if (typeof name !== 'string' || typeof description !== 'string') { + return null; + } + + return { + name, + description, + location: filePath, + body: match[2].trim(), + }; + } catch (error) { + debugLogger.log(`Error parsing skill file ${filePath}:`, error); + return null; + } + } +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f934a66b50..d138c2bcd2 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1373,6 +1373,13 @@ "default": false, "type": "boolean" }, + "skills": { + "title": "Agent Skills", + "description": "Enable Agent Skills (experimental).", + "markdownDescription": "Enable Agent Skills (experimental).\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "codebaseInvestigatorSettings": { "title": "Codebase Investigator Settings", "description": "Configuration for Codebase Investigator.", @@ -1468,6 +1475,26 @@ }, "additionalProperties": false }, + "skills": { + "title": "Skills", + "description": "Settings for agent skills.", + "markdownDescription": "Settings for agent skills.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "disabled": { + "title": "Disabled Skills", + "description": "List of disabled skills.", + "markdownDescription": "List of disabled skills.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `[]`", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "hooks": { "title": "Hooks", "description": "Hook configurations for intercepting and customizing agent behavior.",