feat(cli): enable skill activation via slash commands (#21758)

Co-authored-by: matt korwel <matt.korwel@gmail.com>
This commit is contained in:
N. Taylor Mullen
2026-03-10 12:24:54 -07:00
committed by GitHub
parent be67470432
commit 5d213764fb
9 changed files with 249 additions and 1 deletions
@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { SkillCommandLoader } from './SkillCommandLoader.js';
import { CommandKind } from '../ui/commands/types.js';
import { ACTIVATE_SKILL_TOOL_NAME } from '@google/gemini-cli-core';
describe('SkillCommandLoader', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockConfig: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockSkillManager: any;
beforeEach(() => {
mockSkillManager = {
getDisplayableSkills: vi.fn(),
isAdminEnabled: vi.fn().mockReturnValue(true),
};
mockConfig = {
isSkillsSupportEnabled: vi.fn().mockReturnValue(true),
getSkillManager: vi.fn().mockReturnValue(mockSkillManager),
};
});
it('should return an empty array if skills support is disabled', async () => {
mockConfig.isSkillsSupportEnabled.mockReturnValue(false);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toEqual([]);
});
it('should return an empty array if SkillManager is missing', async () => {
mockConfig.getSkillManager.mockReturnValue(null);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toEqual([]);
});
it('should return an empty array if skills are admin-disabled', async () => {
mockSkillManager.isAdminEnabled.mockReturnValue(false);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toEqual([]);
});
it('should load skills as slash commands', async () => {
const mockSkills = [
{ name: 'skill1', description: 'Description 1' },
{ name: 'skill2', description: '' },
];
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toHaveLength(2);
expect(commands[0]).toMatchObject({
name: 'skill1',
description: 'Description 1',
kind: CommandKind.SKILL,
autoExecute: true,
});
expect(commands[1]).toMatchObject({
name: 'skill2',
description: 'Activate the skill2 skill',
kind: CommandKind.SKILL,
autoExecute: true,
});
});
it('should return a tool action when a skill command is executed', async () => {
const mockSkills = [{ name: 'test-skill', description: 'Test skill' }];
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actionResult = await commands[0].action!({} as any, '');
expect(actionResult).toEqual({
type: 'tool',
toolName: ACTIVATE_SKILL_TOOL_NAME,
toolArgs: { name: 'test-skill' },
postSubmitPrompt: undefined,
});
});
it('should return a tool action with postSubmitPrompt when args are provided', async () => {
const mockSkills = [{ name: 'test-skill', description: 'Test skill' }];
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actionResult = await commands[0].action!({} as any, 'hello world');
expect(actionResult).toEqual({
type: 'tool',
toolName: ACTIVATE_SKILL_TOOL_NAME,
toolArgs: { name: 'test-skill' },
postSubmitPrompt: 'hello world',
});
});
it('should sanitize skill names with spaces', async () => {
const mockSkills = [{ name: 'my awesome skill', description: 'Desc' }];
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands[0].name).toBe('my-awesome-skill');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actionResult = (await commands[0].action!({} as any, '')) as any;
expect(actionResult.toolArgs).toEqual({ name: 'my awesome skill' });
});
});
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type Config, ACTIVATE_SKILL_TOOL_NAME } from '@google/gemini-cli-core';
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
import { type ICommandLoader } from './types.js';
/**
* Loads Agent Skills as slash commands.
*/
export class SkillCommandLoader implements ICommandLoader {
constructor(private config: Config | null) {}
/**
* Discovers all available skills from the SkillManager and converts
* them into executable slash commands.
*
* @param _signal An AbortSignal (unused for this synchronous loader).
* @returns A promise that resolves to an array of `SlashCommand` objects.
*/
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
if (!this.config || !this.config.isSkillsSupportEnabled()) {
return [];
}
const skillManager = this.config.getSkillManager();
if (!skillManager || !skillManager.isAdminEnabled()) {
return [];
}
// Convert all displayable skills into slash commands.
const skills = skillManager.getDisplayableSkills();
return skills.map((skill) => {
const commandName = skill.name.trim().replace(/\s+/g, '-');
return {
name: commandName,
description: skill.description || `Activate the ${skill.name} skill`,
kind: CommandKind.SKILL,
autoExecute: true,
action: async (_context, args) => ({
type: 'tool',
toolName: ACTIVATE_SKILL_TOOL_NAME,
toolArgs: { name: skill.name },
postSubmitPrompt: args.trim().length > 0 ? args.trim() : undefined,
}),
};
});
}
}