mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
Agent Skills: Implement Core Skill Infrastructure & Tiered Discovery (#15698)
This commit is contained in:
@@ -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):
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
227
packages/cli/src/ui/commands/skillsCommand.test.ts
Normal file
227
packages/cli/src/ui/commands/skillsCommand.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
213
packages/cli/src/ui/commands/skillsCommand.ts
Normal file
213
packages/cli/src/ui/commands/skillsCommand.ts
Normal 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,
|
||||
};
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
90
packages/cli/src/ui/components/views/SkillsList.test.tsx
Normal file
90
packages/cli/src/ui/components/views/SkillsList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
85
packages/cli/src/ui/components/views/SkillsList.tsx
Normal file
85
packages/cli/src/ui/components/views/SkillsList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
237
packages/core/src/services/skillManager.test.ts
Normal file
237
packages/core/src/services/skillManager.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
197
packages/core/src/services/skillManager.ts
Normal file
197
packages/core/src/services/skillManager.ts
Normal file
@@ -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<string> = 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<void> {
|
||||
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<string, SkillMetadata>();
|
||||
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<SkillMetadata[]> {
|
||||
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<SkillMetadata | null> {
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user