Skip to content

Commit 5d21376

Browse files
feat(cli): enable skill activation via slash commands (#21758)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
1 parent be67470 commit 5d21376

9 files changed

Lines changed: 249 additions & 1 deletion

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { vi, describe, it, expect, beforeEach } from 'vitest';
8+
import { SkillCommandLoader } from './SkillCommandLoader.js';
9+
import { CommandKind } from '../ui/commands/types.js';
10+
import { ACTIVATE_SKILL_TOOL_NAME } from '@google/gemini-cli-core';
11+
12+
describe('SkillCommandLoader', () => {
13+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
14+
let mockConfig: any;
15+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16+
let mockSkillManager: any;
17+
18+
beforeEach(() => {
19+
mockSkillManager = {
20+
getDisplayableSkills: vi.fn(),
21+
isAdminEnabled: vi.fn().mockReturnValue(true),
22+
};
23+
24+
mockConfig = {
25+
isSkillsSupportEnabled: vi.fn().mockReturnValue(true),
26+
getSkillManager: vi.fn().mockReturnValue(mockSkillManager),
27+
};
28+
});
29+
30+
it('should return an empty array if skills support is disabled', async () => {
31+
mockConfig.isSkillsSupportEnabled.mockReturnValue(false);
32+
const loader = new SkillCommandLoader(mockConfig);
33+
const commands = await loader.loadCommands(new AbortController().signal);
34+
expect(commands).toEqual([]);
35+
});
36+
37+
it('should return an empty array if SkillManager is missing', async () => {
38+
mockConfig.getSkillManager.mockReturnValue(null);
39+
const loader = new SkillCommandLoader(mockConfig);
40+
const commands = await loader.loadCommands(new AbortController().signal);
41+
expect(commands).toEqual([]);
42+
});
43+
44+
it('should return an empty array if skills are admin-disabled', async () => {
45+
mockSkillManager.isAdminEnabled.mockReturnValue(false);
46+
const loader = new SkillCommandLoader(mockConfig);
47+
const commands = await loader.loadCommands(new AbortController().signal);
48+
expect(commands).toEqual([]);
49+
});
50+
51+
it('should load skills as slash commands', async () => {
52+
const mockSkills = [
53+
{ name: 'skill1', description: 'Description 1' },
54+
{ name: 'skill2', description: '' },
55+
];
56+
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
57+
58+
const loader = new SkillCommandLoader(mockConfig);
59+
const commands = await loader.loadCommands(new AbortController().signal);
60+
61+
expect(commands).toHaveLength(2);
62+
63+
expect(commands[0]).toMatchObject({
64+
name: 'skill1',
65+
description: 'Description 1',
66+
kind: CommandKind.SKILL,
67+
autoExecute: true,
68+
});
69+
70+
expect(commands[1]).toMatchObject({
71+
name: 'skill2',
72+
description: 'Activate the skill2 skill',
73+
kind: CommandKind.SKILL,
74+
autoExecute: true,
75+
});
76+
});
77+
78+
it('should return a tool action when a skill command is executed', async () => {
79+
const mockSkills = [{ name: 'test-skill', description: 'Test skill' }];
80+
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
81+
82+
const loader = new SkillCommandLoader(mockConfig);
83+
const commands = await loader.loadCommands(new AbortController().signal);
84+
85+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
86+
const actionResult = await commands[0].action!({} as any, '');
87+
expect(actionResult).toEqual({
88+
type: 'tool',
89+
toolName: ACTIVATE_SKILL_TOOL_NAME,
90+
toolArgs: { name: 'test-skill' },
91+
postSubmitPrompt: undefined,
92+
});
93+
});
94+
95+
it('should return a tool action with postSubmitPrompt when args are provided', async () => {
96+
const mockSkills = [{ name: 'test-skill', description: 'Test skill' }];
97+
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
98+
99+
const loader = new SkillCommandLoader(mockConfig);
100+
const commands = await loader.loadCommands(new AbortController().signal);
101+
102+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
103+
const actionResult = await commands[0].action!({} as any, 'hello world');
104+
expect(actionResult).toEqual({
105+
type: 'tool',
106+
toolName: ACTIVATE_SKILL_TOOL_NAME,
107+
toolArgs: { name: 'test-skill' },
108+
postSubmitPrompt: 'hello world',
109+
});
110+
});
111+
112+
it('should sanitize skill names with spaces', async () => {
113+
const mockSkills = [{ name: 'my awesome skill', description: 'Desc' }];
114+
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
115+
116+
const loader = new SkillCommandLoader(mockConfig);
117+
const commands = await loader.loadCommands(new AbortController().signal);
118+
119+
expect(commands[0].name).toBe('my-awesome-skill');
120+
121+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
122+
const actionResult = (await commands[0].action!({} as any, '')) as any;
123+
expect(actionResult.toolArgs).toEqual({ name: 'my awesome skill' });
124+
});
125+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { type Config, ACTIVATE_SKILL_TOOL_NAME } from '@google/gemini-cli-core';
8+
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
9+
import { type ICommandLoader } from './types.js';
10+
11+
/**
12+
* Loads Agent Skills as slash commands.
13+
*/
14+
export class SkillCommandLoader implements ICommandLoader {
15+
constructor(private config: Config | null) {}
16+
17+
/**
18+
* Discovers all available skills from the SkillManager and converts
19+
* them into executable slash commands.
20+
*
21+
* @param _signal An AbortSignal (unused for this synchronous loader).
22+
* @returns A promise that resolves to an array of `SlashCommand` objects.
23+
*/
24+
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
25+
if (!this.config || !this.config.isSkillsSupportEnabled()) {
26+
return [];
27+
}
28+
29+
const skillManager = this.config.getSkillManager();
30+
if (!skillManager || !skillManager.isAdminEnabled()) {
31+
return [];
32+
}
33+
34+
// Convert all displayable skills into slash commands.
35+
const skills = skillManager.getDisplayableSkills();
36+
37+
return skills.map((skill) => {
38+
const commandName = skill.name.trim().replace(/\s+/g, '-');
39+
return {
40+
name: commandName,
41+
description: skill.description || `Activate the ${skill.name} skill`,
42+
kind: CommandKind.SKILL,
43+
autoExecute: true,
44+
action: async (_context, args) => ({
45+
type: 'tool',
46+
toolName: ACTIVATE_SKILL_TOOL_NAME,
47+
toolArgs: { name: skill.name },
48+
postSubmitPrompt: args.trim().length > 0 ? args.trim() : undefined,
49+
}),
50+
};
51+
});
52+
}
53+
}

packages/cli/src/ui/commands/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export enum CommandKind {
182182
EXTENSION_FILE = 'extension-file',
183183
MCP_PROMPT = 'mcp-prompt',
184184
AGENT = 'agent',
185+
SKILL = 'skill',
185186
}
186187

187188
// The standardized contract for any command in the system.

packages/cli/src/ui/hooks/slashCommandProcessor.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { CommandService } from '../../services/CommandService.js';
5252
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
5353
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
5454
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
55+
import { SkillCommandLoader } from '../../services/SkillCommandLoader.js';
5556
import { parseSlashCommand } from '../../utils/commands.js';
5657
import {
5758
type ExtensionUpdateAction,
@@ -324,6 +325,7 @@ export const useSlashCommandProcessor = (
324325
(async () => {
325326
const commandService = await CommandService.create(
326327
[
328+
new SkillCommandLoader(config),
327329
new McpPromptLoader(config),
328330
new BuiltinCommandLoader(config),
329331
new FileCommandLoader(config),
@@ -445,6 +447,7 @@ export const useSlashCommandProcessor = (
445447
type: 'schedule_tool',
446448
toolName: result.toolName,
447449
toolArgs: result.toolArgs,
450+
postSubmitPrompt: result.postSubmitPrompt,
448451
};
449452
case 'message':
450453
addItem(

packages/cli/src/ui/hooks/useGeminiStream.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,8 @@ export const useGeminiStream = (
759759
if (slashCommandResult) {
760760
switch (slashCommandResult.type) {
761761
case 'schedule_tool': {
762-
const { toolName, toolArgs } = slashCommandResult;
762+
const { toolName, toolArgs, postSubmitPrompt } =
763+
slashCommandResult;
763764
const toolCallRequest: ToolCallRequestInfo = {
764765
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
765766
name: toolName,
@@ -768,6 +769,15 @@ export const useGeminiStream = (
768769
prompt_id,
769770
};
770771
await scheduleToolCalls([toolCallRequest], abortSignal);
772+
773+
if (postSubmitPrompt) {
774+
localQueryToSendToGemini = postSubmitPrompt;
775+
return {
776+
queryToSend: localQueryToSendToGemini,
777+
shouldProceed: true,
778+
};
779+
}
780+
771781
return { queryToSend: null, shouldProceed: false };
772782
}
773783
case 'submit_prompt': {

packages/cli/src/ui/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,7 @@ export type SlashCommandProcessorResult =
483483
type: 'schedule_tool';
484484
toolName: string;
485485
toolArgs: Record<string, unknown>;
486+
postSubmitPrompt?: PartListUnion;
486487
}
487488
| {
488489
type: 'handled'; // Indicates the command was processed and no further action is needed.

packages/core/src/commands/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ export interface ToolActionReturn {
1212
type: 'tool';
1313
toolName: string;
1414
toolArgs: Record<string, unknown>;
15+
/**
16+
* Optional content to be submitted as a prompt to the Gemini model
17+
* after the tool call completes.
18+
*/
19+
postSubmitPrompt?: PartListUnion;
1520
}
1621

1722
/**

packages/core/src/scheduler/policy.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,43 @@ describe('policy.ts', () => {
164164
const result = await checkPolicy(toolCall, mockConfig);
165165
expect(result.decision).toBe(PolicyDecision.ASK_USER);
166166
});
167+
168+
it('should return ALLOW if decision is ASK_USER and request is client-initiated', async () => {
169+
const mockPolicyEngine = {
170+
check: vi.fn().mockResolvedValue({ decision: PolicyDecision.ASK_USER }),
171+
} as unknown as Mocked<PolicyEngine>;
172+
173+
const mockConfig = {
174+
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
175+
isInteractive: vi.fn().mockReturnValue(true),
176+
} as unknown as Mocked<Config>;
177+
178+
const toolCall = {
179+
request: { name: 'test-tool', args: {}, isClientInitiated: true },
180+
tool: { name: 'test-tool' },
181+
} as ValidatingToolCall;
182+
183+
const result = await checkPolicy(toolCall, mockConfig);
184+
expect(result.decision).toBe(PolicyDecision.ALLOW);
185+
});
186+
187+
it('should still return DENY if request is client-initiated but policy says DENY', async () => {
188+
const mockPolicyEngine = {
189+
check: vi.fn().mockResolvedValue({ decision: PolicyDecision.DENY }),
190+
} as unknown as Mocked<PolicyEngine>;
191+
192+
const mockConfig = {
193+
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
194+
} as unknown as Mocked<Config>;
195+
196+
const toolCall = {
197+
request: { name: 'test-tool', args: {}, isClientInitiated: true },
198+
tool: { name: 'test-tool' },
199+
} as ValidatingToolCall;
200+
201+
const result = await checkPolicy(toolCall, mockConfig);
202+
expect(result.decision).toBe(PolicyDecision.DENY);
203+
});
167204
});
168205

169206
describe('updatePolicy', () => {

packages/core/src/scheduler/policy.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ export async function checkPolicy(
6969

7070
const { decision } = result;
7171

72+
// If the tool call was initiated by the client (e.g. via a slash command),
73+
// we treat it as implicitly confirmed by the user and bypass the
74+
// confirmation prompt if the policy engine's decision is 'ASK_USER'.
75+
if (
76+
decision === PolicyDecision.ASK_USER &&
77+
toolCall.request.isClientInitiated
78+
) {
79+
return {
80+
decision: PolicyDecision.ALLOW,
81+
rule: result.rule,
82+
};
83+
}
84+
7285
/*
7386
* Return the full check result including the rule that matched.
7487
* This is necessary to access metadata like custom deny messages.

0 commit comments

Comments
 (0)