Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions packages/cli/src/services/SkillCommandLoader.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 53 additions & 0 deletions packages/cli/src/services/SkillCommandLoader.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
};
});
}
}
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/ui/hooks/slashCommandProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -445,6 +447,7 @@ export const useSlashCommandProcessor = (
type: 'schedule_tool',
toolName: result.toolName,
toolArgs: result.toolArgs,
postSubmitPrompt: result.postSubmitPrompt,
};
case 'message':
addItem(
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,7 +747,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,
Expand All @@ -756,6 +757,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': {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/scheduler/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,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', () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/scheduler/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,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.
Expand Down
Loading