mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-02 07:54:48 -07:00
feat(cli): enable skill activation via slash commands
- Register agent skills as dynamic slash commands using SkillCommandLoader - Allow client-initiated tool calls to skip user confirmation if policy is ASK_USER - Support follow-up prompt in skill slash commands via postSubmitPrompt - Add tests for SkillCommandLoader and policy bypass
This commit is contained in:
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* @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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include enabled skills that are not built-in (since built-in skills
|
||||||
|
// are usually internal or have dedicated slash commands).
|
||||||
|
// Actually, the user says "if you have a skill called google-foo", so we
|
||||||
|
// should probably include all user/workspace/extension skills.
|
||||||
|
const skills = skillManager.getDisplayableSkills();
|
||||||
|
|
||||||
|
return skills.map((skill) => ({
|
||||||
|
name: skill.name,
|
||||||
|
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',
|
EXTENSION_FILE = 'extension-file',
|
||||||
MCP_PROMPT = 'mcp-prompt',
|
MCP_PROMPT = 'mcp-prompt',
|
||||||
AGENT = 'agent',
|
AGENT = 'agent',
|
||||||
|
SKILL = 'skill',
|
||||||
}
|
}
|
||||||
|
|
||||||
// The standardized contract for any command in the system.
|
// 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 { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
||||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||||
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
||||||
|
import { SkillCommandLoader } from '../../services/SkillCommandLoader.js';
|
||||||
import { parseSlashCommand } from '../../utils/commands.js';
|
import { parseSlashCommand } from '../../utils/commands.js';
|
||||||
import {
|
import {
|
||||||
type ExtensionUpdateAction,
|
type ExtensionUpdateAction,
|
||||||
@@ -324,6 +325,7 @@ export const useSlashCommandProcessor = (
|
|||||||
(async () => {
|
(async () => {
|
||||||
const commandService = await CommandService.create(
|
const commandService = await CommandService.create(
|
||||||
[
|
[
|
||||||
|
new SkillCommandLoader(config),
|
||||||
new McpPromptLoader(config),
|
new McpPromptLoader(config),
|
||||||
new BuiltinCommandLoader(config),
|
new BuiltinCommandLoader(config),
|
||||||
new FileCommandLoader(config),
|
new FileCommandLoader(config),
|
||||||
@@ -445,6 +447,7 @@ export const useSlashCommandProcessor = (
|
|||||||
type: 'schedule_tool',
|
type: 'schedule_tool',
|
||||||
toolName: result.toolName,
|
toolName: result.toolName,
|
||||||
toolArgs: result.toolArgs,
|
toolArgs: result.toolArgs,
|
||||||
|
postSubmitPrompt: result.postSubmitPrompt,
|
||||||
};
|
};
|
||||||
case 'message':
|
case 'message':
|
||||||
addItem(
|
addItem(
|
||||||
|
|||||||
@@ -747,7 +747,8 @@ export const useGeminiStream = (
|
|||||||
if (slashCommandResult) {
|
if (slashCommandResult) {
|
||||||
switch (slashCommandResult.type) {
|
switch (slashCommandResult.type) {
|
||||||
case 'schedule_tool': {
|
case 'schedule_tool': {
|
||||||
const { toolName, toolArgs } = slashCommandResult;
|
const { toolName, toolArgs, postSubmitPrompt } =
|
||||||
|
slashCommandResult;
|
||||||
const toolCallRequest: ToolCallRequestInfo = {
|
const toolCallRequest: ToolCallRequestInfo = {
|
||||||
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
name: toolName,
|
name: toolName,
|
||||||
@@ -756,6 +757,15 @@ export const useGeminiStream = (
|
|||||||
prompt_id,
|
prompt_id,
|
||||||
};
|
};
|
||||||
await scheduleToolCalls([toolCallRequest], abortSignal);
|
await scheduleToolCalls([toolCallRequest], abortSignal);
|
||||||
|
|
||||||
|
if (postSubmitPrompt) {
|
||||||
|
localQueryToSendToGemini = postSubmitPrompt;
|
||||||
|
return {
|
||||||
|
queryToSend: localQueryToSendToGemini,
|
||||||
|
shouldProceed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { queryToSend: null, shouldProceed: false };
|
return { queryToSend: null, shouldProceed: false };
|
||||||
}
|
}
|
||||||
case 'submit_prompt': {
|
case 'submit_prompt': {
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ export type SlashCommandProcessorResult =
|
|||||||
type: 'schedule_tool';
|
type: 'schedule_tool';
|
||||||
toolName: string;
|
toolName: string;
|
||||||
toolArgs: Record<string, unknown>;
|
toolArgs: Record<string, unknown>;
|
||||||
|
postSubmitPrompt?: PartListUnion;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'handled'; // Indicates the command was processed and no further action is needed.
|
type: 'handled'; // Indicates the command was processed and no further action is needed.
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export interface ToolActionReturn {
|
|||||||
type: 'tool';
|
type: 'tool';
|
||||||
toolName: string;
|
toolName: string;
|
||||||
toolArgs: Record<string, unknown>;
|
toolArgs: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Optional content to be submitted as a prompt to the Gemini model
|
||||||
|
* after the tool call completes.
|
||||||
|
*/
|
||||||
|
postSubmitPrompt?: PartListUnion;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -145,6 +145,43 @@ describe('policy.ts', () => {
|
|||||||
const result = await checkPolicy(toolCall, mockConfig);
|
const result = await checkPolicy(toolCall, mockConfig);
|
||||||
expect(result.decision).toBe(PolicyDecision.ASK_USER);
|
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', () => {
|
describe('updatePolicy', () => {
|
||||||
|
|||||||
@@ -66,6 +66,19 @@ export async function checkPolicy(
|
|||||||
|
|
||||||
const { decision } = result;
|
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.
|
* Return the full check result including the rule that matched.
|
||||||
* This is necessary to access metadata like custom deny messages.
|
* This is necessary to access metadata like custom deny messages.
|
||||||
|
|||||||
Reference in New Issue
Block a user