diff --git a/packages/cli/src/services/SkillCommandLoader.test.ts b/packages/cli/src/services/SkillCommandLoader.test.ts new file mode 100644 index 0000000000..15a2ebec18 --- /dev/null +++ b/packages/cli/src/services/SkillCommandLoader.test.ts @@ -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' }); + }); +}); diff --git a/packages/cli/src/services/SkillCommandLoader.ts b/packages/cli/src/services/SkillCommandLoader.ts new file mode 100644 index 0000000000..85f1884299 --- /dev/null +++ b/packages/cli/src/services/SkillCommandLoader.ts @@ -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 { + 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, + }), + }; + }); + } +} diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index e4f0d0ad52..28f52461e4 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -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. diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 20a76dcf43..6f3ecd7b96 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -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( diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index d2e485db1f..6b6c4554f2 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -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': { diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 3898461fb0..2f8e414a83 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -483,6 +483,7 @@ export type SlashCommandProcessorResult = type: 'schedule_tool'; toolName: string; toolArgs: Record; + postSubmitPrompt?: PartListUnion; } | { type: 'handled'; // Indicates the command was processed and no further action is needed. diff --git a/packages/core/src/commands/types.ts b/packages/core/src/commands/types.ts index d9cc7a24e9..62bda279af 100644 --- a/packages/core/src/commands/types.ts +++ b/packages/core/src/commands/types.ts @@ -12,6 +12,11 @@ export interface ToolActionReturn { type: 'tool'; toolName: string; toolArgs: Record; + /** + * Optional content to be submitted as a prompt to the Gemini model + * after the tool call completes. + */ + postSubmitPrompt?: PartListUnion; } /** diff --git a/packages/core/src/scheduler/policy.test.ts b/packages/core/src/scheduler/policy.test.ts index 4bf2b32a46..fc81d2dc69 100644 --- a/packages/core/src/scheduler/policy.test.ts +++ b/packages/core/src/scheduler/policy.test.ts @@ -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; + + const mockConfig = { + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + isInteractive: vi.fn().mockReturnValue(true), + } as unknown as Mocked; + + 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; + + const mockConfig = { + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + } as unknown as Mocked; + + 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', () => { diff --git a/packages/core/src/scheduler/policy.ts b/packages/core/src/scheduler/policy.ts index 1ac70a108b..c0ea06f59b 100644 --- a/packages/core/src/scheduler/policy.ts +++ b/packages/core/src/scheduler/policy.ts @@ -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.