Agent Skills: Implement Core Skill Infrastructure & Tiered Discovery (#15698)

This commit is contained in:
N. Taylor Mullen
2025-12-30 13:35:52 -08:00
committed by GitHub
parent ec11b8afbf
commit de1233b8ca
19 changed files with 1209 additions and 3 deletions

View File

@@ -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,

View File

@@ -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',

View File

@@ -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;
});

View File

@@ -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,

View File

@@ -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']);
});
});
});

View File

@@ -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<void | SlashCommandActionReturn> {
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<void | SlashCommandActionReturn> {
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<void | SlashCommandActionReturn> {
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 <name> | enable <name>]',
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 <name>',
kind: CommandKind.BUILT_IN,
action: disableAction,
completion: disableCompletion,
},
{
name: 'enable',
description:
'Enable a disabled skill by name. Usage: /skills enable <name>',
kind: CommandKind.BUILT_IN,
action: enableAction,
completion: enableCompletion,
},
],
action: listAction,
};

View File

@@ -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<HistoryItemDisplayProps> = ({
showDescriptions={itemForDisplay.showDescriptions}
/>
)}
{itemForDisplay.type === 'skills_list' && (
<SkillsList
skills={itemForDisplay.skills}
showDescriptions={itemForDisplay.showDescriptions}
/>
)}
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}

View File

@@ -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(
<SkillsList skills={mockSkills} showDescriptions={true} />,
);
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(
<SkillsList skills={mockSkills} showDescriptions={false} />,
);
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(
<SkillsList skills={[]} showDescriptions={true} />,
);
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(
<SkillsList skills={enabledOnly} showDescriptions={true} />,
);
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(
<SkillsList skills={disabledOnly} showDescriptions={true} />,
);
const output = lastFrame();
expect(output).not.toContain('Available Agent Skills:');
expect(output).toContain('Disabled Skills:');
unmount();
});
});

View File

@@ -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<SkillsListProps> = ({
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) => (
<Box key={skill.name} flexDirection="row">
<Text color={theme.text.primary}>{' '}- </Text>
<Box flexDirection="column">
<Text
bold
color={skill.disabled ? theme.text.secondary : theme.text.link}
>
{skill.name}
</Text>
{showDescriptions && skill.description && (
<Box marginLeft={2}>
<Text
color={skill.disabled ? theme.text.secondary : theme.text.primary}
>
{skill.description}
</Text>
</Box>
)}
</Box>
</Box>
);
return (
<Box flexDirection="column" marginBottom={1}>
{enabledSkills.length > 0 && (
<Box flexDirection="column">
<Text bold color={theme.text.primary}>
Available Agent Skills:
</Text>
<Box height={1} />
{enabledSkills.map(renderSkill)}
</Box>
)}
{enabledSkills.length > 0 && disabledSkills.length > 0 && (
<Box marginY={1}>
<Text color={theme.text.secondary}>{'-'.repeat(20)}</Text>
</Box>
)}
{disabledSkills.length > 0 && (
<Box flexDirection="column">
<Text bold color={theme.text.secondary}>
Disabled Skills:
</Text>
<Box height={1} />
{disabledSkills.map(renderSkill)}
</Box>
)}
{skills.length === 0 && (
<Text color={theme.text.primary}> No skills available</Text>
)}
</Box>
);
};

View File

@@ -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',