mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
feat(cli): enable skill activation via slash commands (#21758)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
This commit is contained in:
125
packages/cli/src/services/SkillCommandLoader.test.ts
Normal file
125
packages/cli/src/services/SkillCommandLoader.test.ts
Normal file
@@ -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' });
|
||||
});
|
||||
});
|
||||
53
packages/cli/src/services/SkillCommandLoader.ts
Normal file
53
packages/cli/src/services/SkillCommandLoader.ts
Normal file
@@ -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,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,7 @@ export enum CommandKind {
|
||||
EXTENSION_FILE = 'extension-file',
|
||||
MCP_PROMPT = 'mcp-prompt',
|
||||
AGENT = 'agent',
|
||||
SKILL = 'skill',
|
||||
}
|
||||
|
||||
// The standardized contract for any command in the system.
|
||||
|
||||
@@ -52,6 +52,7 @@ import { CommandService } from '../../services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
||||
import { SkillCommandLoader } from '../../services/SkillCommandLoader.js';
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
import {
|
||||
type ExtensionUpdateAction,
|
||||
@@ -324,6 +325,7 @@ export const useSlashCommandProcessor = (
|
||||
(async () => {
|
||||
const commandService = await CommandService.create(
|
||||
[
|
||||
new SkillCommandLoader(config),
|
||||
new McpPromptLoader(config),
|
||||
new BuiltinCommandLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
@@ -445,6 +447,7 @@ export const useSlashCommandProcessor = (
|
||||
type: 'schedule_tool',
|
||||
toolName: result.toolName,
|
||||
toolArgs: result.toolArgs,
|
||||
postSubmitPrompt: result.postSubmitPrompt,
|
||||
};
|
||||
case 'message':
|
||||
addItem(
|
||||
|
||||
@@ -759,7 +759,8 @@ export const useGeminiStream = (
|
||||
if (slashCommandResult) {
|
||||
switch (slashCommandResult.type) {
|
||||
case 'schedule_tool': {
|
||||
const { toolName, toolArgs } = slashCommandResult;
|
||||
const { toolName, toolArgs, postSubmitPrompt } =
|
||||
slashCommandResult;
|
||||
const toolCallRequest: ToolCallRequestInfo = {
|
||||
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
name: toolName,
|
||||
@@ -768,6 +769,15 @@ export const useGeminiStream = (
|
||||
prompt_id,
|
||||
};
|
||||
await scheduleToolCalls([toolCallRequest], abortSignal);
|
||||
|
||||
if (postSubmitPrompt) {
|
||||
localQueryToSendToGemini = postSubmitPrompt;
|
||||
return {
|
||||
queryToSend: localQueryToSendToGemini,
|
||||
shouldProceed: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { queryToSend: null, shouldProceed: false };
|
||||
}
|
||||
case 'submit_prompt': {
|
||||
|
||||
@@ -483,6 +483,7 @@ export type SlashCommandProcessorResult =
|
||||
type: 'schedule_tool';
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
postSubmitPrompt?: PartListUnion;
|
||||
}
|
||||
| {
|
||||
type: 'handled'; // Indicates the command was processed and no further action is needed.
|
||||
|
||||
@@ -12,6 +12,11 @@ export interface ToolActionReturn {
|
||||
type: 'tool';
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
/**
|
||||
* Optional content to be submitted as a prompt to the Gemini model
|
||||
* after the tool call completes.
|
||||
*/
|
||||
postSubmitPrompt?: PartListUnion;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -164,6 +164,43 @@ describe('policy.ts', () => {
|
||||
const result = await checkPolicy(toolCall, mockConfig);
|
||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should return ALLOW if decision is ASK_USER and request is client-initiated', async () => {
|
||||
const mockPolicyEngine = {
|
||||
check: vi.fn().mockResolvedValue({ decision: PolicyDecision.ASK_USER }),
|
||||
} as unknown as Mocked<PolicyEngine>;
|
||||
|
||||
const mockConfig = {
|
||||
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Mocked<Config>;
|
||||
|
||||
const toolCall = {
|
||||
request: { name: 'test-tool', args: {}, isClientInitiated: true },
|
||||
tool: { name: 'test-tool' },
|
||||
} as ValidatingToolCall;
|
||||
|
||||
const result = await checkPolicy(toolCall, mockConfig);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should still return DENY if request is client-initiated but policy says DENY', async () => {
|
||||
const mockPolicyEngine = {
|
||||
check: vi.fn().mockResolvedValue({ decision: PolicyDecision.DENY }),
|
||||
} as unknown as Mocked<PolicyEngine>;
|
||||
|
||||
const mockConfig = {
|
||||
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
|
||||
} as unknown as Mocked<Config>;
|
||||
|
||||
const toolCall = {
|
||||
request: { name: 'test-tool', args: {}, isClientInitiated: true },
|
||||
tool: { name: 'test-tool' },
|
||||
} as ValidatingToolCall;
|
||||
|
||||
const result = await checkPolicy(toolCall, mockConfig);
|
||||
expect(result.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePolicy', () => {
|
||||
|
||||
@@ -69,6 +69,19 @@ export async function checkPolicy(
|
||||
|
||||
const { decision } = result;
|
||||
|
||||
// If the tool call was initiated by the client (e.g. via a slash command),
|
||||
// we treat it as implicitly confirmed by the user and bypass the
|
||||
// confirmation prompt if the policy engine's decision is 'ASK_USER'.
|
||||
if (
|
||||
decision === PolicyDecision.ASK_USER &&
|
||||
toolCall.request.isClientInitiated
|
||||
) {
|
||||
return {
|
||||
decision: PolicyDecision.ALLOW,
|
||||
rule: result.rule,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Return the full check result including the rule that matched.
|
||||
* This is necessary to access metadata like custom deny messages.
|
||||
|
||||
Reference in New Issue
Block a user