mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
408 lines
10 KiB
TypeScript
408 lines
10 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
type CommandContext,
|
|
type SlashCommand,
|
|
type SlashCommandActionReturn,
|
|
CommandKind,
|
|
} from './types.js';
|
|
import {
|
|
type HistoryItemInfo,
|
|
type HistoryItemSkillsList,
|
|
MessageType,
|
|
} from '../types.js';
|
|
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
|
|
import { getErrorMessage } from '../../utils/errors.js';
|
|
|
|
import { getAdminErrorMessage } from '@google/gemini-cli-core';
|
|
import {
|
|
linkSkill,
|
|
renderSkillActionFeedback,
|
|
} from '../../utils/skillUtils.js';
|
|
import { SettingScope } from '../../config/settings.js';
|
|
import {
|
|
requestConsentInteractive,
|
|
skillsConsentString,
|
|
} from '../../config/extensions/consent.js';
|
|
|
|
async function listAction(
|
|
context: CommandContext,
|
|
args: string,
|
|
): Promise<void | SlashCommandActionReturn> {
|
|
const subArgs = args.trim().split(/\s+/);
|
|
|
|
// Default to SHOWING descriptions. The user can hide them with 'nodesc'.
|
|
let useShowDescriptions = true;
|
|
let showAll = false;
|
|
|
|
for (const arg of subArgs) {
|
|
if (arg === 'nodesc' || arg === '--nodesc') {
|
|
useShowDescriptions = false;
|
|
} else if (arg === 'all' || arg === '--all') {
|
|
showAll = true;
|
|
}
|
|
}
|
|
|
|
const skillManager = context.services.config?.getSkillManager();
|
|
if (!skillManager) {
|
|
context.ui.addItem({
|
|
type: MessageType.ERROR,
|
|
text: 'Could not retrieve skill manager.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const skills = showAll
|
|
? skillManager.getAllSkills()
|
|
: skillManager.getAllSkills().filter((s) => !s.isBuiltin);
|
|
|
|
const skillsListItem: HistoryItemSkillsList = {
|
|
type: MessageType.SKILLS_LIST,
|
|
skills: skills.map((skill) => ({
|
|
name: skill.name,
|
|
description: skill.description,
|
|
disabled: skill.disabled,
|
|
location: skill.location,
|
|
body: skill.body,
|
|
isBuiltin: skill.isBuiltin,
|
|
})),
|
|
showDescriptions: useShowDescriptions,
|
|
};
|
|
|
|
context.ui.addItem(skillsListItem);
|
|
}
|
|
|
|
async function linkAction(
|
|
context: CommandContext,
|
|
args: string,
|
|
): Promise<void | SlashCommandActionReturn> {
|
|
const parts = args.trim().split(/\s+/);
|
|
const sourcePath = parts[0];
|
|
|
|
if (!sourcePath) {
|
|
context.ui.addItem({
|
|
type: MessageType.ERROR,
|
|
text: 'Usage: /skills link <path> [--scope user|workspace]',
|
|
});
|
|
return;
|
|
}
|
|
|
|
let scopeArg = 'user';
|
|
if (parts.length >= 3 && parts[1] === '--scope') {
|
|
scopeArg = parts[2];
|
|
} else if (parts.length >= 2 && parts[1].startsWith('--scope=')) {
|
|
scopeArg = parts[1].split('=')[1];
|
|
}
|
|
|
|
const scope = scopeArg === 'workspace' ? 'workspace' : 'user';
|
|
|
|
try {
|
|
await linkSkill(
|
|
sourcePath,
|
|
scope,
|
|
(msg) =>
|
|
context.ui.addItem({
|
|
type: MessageType.INFO,
|
|
text: msg,
|
|
}),
|
|
async (skills, targetDir) => {
|
|
const consentString = await skillsConsentString(
|
|
skills,
|
|
sourcePath,
|
|
targetDir,
|
|
true,
|
|
);
|
|
return requestConsentInteractive(
|
|
consentString,
|
|
context.ui.setConfirmationRequest.bind(context.ui),
|
|
);
|
|
},
|
|
);
|
|
|
|
context.ui.addItem({
|
|
type: MessageType.INFO,
|
|
text: `Successfully linked skills from "${sourcePath}" (${scope}).`,
|
|
});
|
|
|
|
if (context.services.config) {
|
|
await context.services.config.reloadSkills();
|
|
}
|
|
} catch (error) {
|
|
context.ui.addItem({
|
|
type: MessageType.ERROR,
|
|
text: `Failed to link skills: ${getErrorMessage(error)}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function disableAction(
|
|
context: CommandContext,
|
|
args: string,
|
|
): Promise<void | SlashCommandActionReturn> {
|
|
const skillName = args.trim();
|
|
if (!skillName) {
|
|
context.ui.addItem({
|
|
type: MessageType.ERROR,
|
|
text: 'Please provide a skill name to disable.',
|
|
});
|
|
return;
|
|
}
|
|
const skillManager = context.services.config?.getSkillManager();
|
|
if (skillManager?.isAdminEnabled() === false) {
|
|
context.ui.addItem(
|
|
{
|
|
type: MessageType.ERROR,
|
|
text: getAdminErrorMessage(
|
|
'Agent skills',
|
|
context.services.config ?? undefined,
|
|
),
|
|
},
|
|
Date.now(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const skill = skillManager?.getSkill(skillName);
|
|
if (!skill) {
|
|
context.ui.addItem(
|
|
{
|
|
type: MessageType.ERROR,
|
|
text: `Skill "${skillName}" not found.`,
|
|
},
|
|
Date.now(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const scope = context.services.settings.workspace.path
|
|
? SettingScope.Workspace
|
|
: SettingScope.User;
|
|
|
|
const result = disableSkill(context.services.settings, skillName, scope);
|
|
|
|
let feedback = renderSkillActionFeedback(
|
|
result,
|
|
(label, path) => `${label} (${path})`,
|
|
);
|
|
if (result.status === 'success' || result.status === 'no-op') {
|
|
feedback +=
|
|
' You can run "/skills reload" to refresh your current instance.';
|
|
}
|
|
|
|
context.ui.addItem({
|
|
type: MessageType.INFO,
|
|
text: feedback,
|
|
});
|
|
}
|
|
|
|
async function enableAction(
|
|
context: CommandContext,
|
|
args: string,
|
|
): Promise<void | SlashCommandActionReturn> {
|
|
const skillName = args.trim();
|
|
if (!skillName) {
|
|
context.ui.addItem({
|
|
type: MessageType.ERROR,
|
|
text: 'Please provide a skill name to enable.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const skillManager = context.services.config?.getSkillManager();
|
|
if (skillManager?.isAdminEnabled() === false) {
|
|
context.ui.addItem(
|
|
{
|
|
type: MessageType.ERROR,
|
|
text: getAdminErrorMessage(
|
|
'Agent skills',
|
|
context.services.config ?? undefined,
|
|
),
|
|
},
|
|
Date.now(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const result = enableSkill(context.services.settings, skillName);
|
|
|
|
let feedback = renderSkillActionFeedback(
|
|
result,
|
|
(label, path) => `${label} (${path})`,
|
|
);
|
|
if (result.status === 'success' || result.status === 'no-op') {
|
|
feedback +=
|
|
' You can run "/skills reload" to refresh your current instance.';
|
|
}
|
|
|
|
context.ui.addItem({
|
|
type: MessageType.INFO,
|
|
text: feedback,
|
|
});
|
|
}
|
|
|
|
async function reloadAction(
|
|
context: CommandContext,
|
|
): Promise<void | SlashCommandActionReturn> {
|
|
const config = context.services.config;
|
|
if (!config) {
|
|
context.ui.addItem({
|
|
type: MessageType.ERROR,
|
|
text: 'Could not retrieve configuration.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const skillManager = config.getSkillManager();
|
|
const beforeNames = new Set(skillManager.getSkills().map((s) => s.name));
|
|
|
|
const startTime = Date.now();
|
|
let pendingItemSet = false;
|
|
const pendingTimeout = setTimeout(() => {
|
|
context.ui.setPendingItem({
|
|
type: MessageType.INFO,
|
|
text: 'Reloading agent skills...',
|
|
});
|
|
pendingItemSet = true;
|
|
}, 100);
|
|
|
|
try {
|
|
await config.reloadSkills();
|
|
|
|
clearTimeout(pendingTimeout);
|
|
if (pendingItemSet) {
|
|
// If we showed the pending item, make sure it stays for at least 500ms
|
|
// total to avoid a "flicker" where it appears and immediately disappears.
|
|
const elapsed = Date.now() - startTime;
|
|
const minVisibleDuration = 500;
|
|
if (elapsed < minVisibleDuration) {
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, minVisibleDuration - elapsed),
|
|
);
|
|
}
|
|
context.ui.setPendingItem(null);
|
|
}
|
|
|
|
const afterSkills = skillManager.getSkills();
|
|
const afterNames = new Set(afterSkills.map((s) => s.name));
|
|
|
|
const added = afterSkills.filter((s) => !beforeNames.has(s.name));
|
|
const removedCount = [...beforeNames].filter(
|
|
(name) => !afterNames.has(name),
|
|
).length;
|
|
|
|
let successText = 'Agent skills reloaded successfully.';
|
|
const details: string[] = [];
|
|
|
|
if (added.length > 0) {
|
|
details.push(
|
|
`${added.length} newly available skill${added.length > 1 ? 's' : ''}`,
|
|
);
|
|
}
|
|
if (removedCount > 0) {
|
|
details.push(
|
|
`${removedCount} skill${removedCount > 1 ? 's' : ''} no longer available`,
|
|
);
|
|
}
|
|
|
|
if (details.length > 0) {
|
|
successText += ` ${details.join(' and ')}.`;
|
|
}
|
|
|
|
context.ui.addItem({
|
|
type: 'info',
|
|
text: successText,
|
|
icon: '✓ ',
|
|
color: 'green',
|
|
} as HistoryItemInfo);
|
|
} catch (error) {
|
|
clearTimeout(pendingTimeout);
|
|
if (pendingItemSet) {
|
|
context.ui.setPendingItem(null);
|
|
}
|
|
context.ui.addItem({
|
|
type: MessageType.ERROR,
|
|
text: `Failed to reload skills: ${error instanceof Error ? error.message : String(error)}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
function disableCompletion(
|
|
context: CommandContext,
|
|
partialArg: string,
|
|
): string[] {
|
|
const skillManager = context.services.config?.getSkillManager();
|
|
if (!skillManager) {
|
|
return [];
|
|
}
|
|
return skillManager
|
|
.getAllSkills()
|
|
.filter((s) => !s.disabled && s.name.startsWith(partialArg))
|
|
.map((s) => s.name);
|
|
}
|
|
|
|
function enableCompletion(
|
|
context: CommandContext,
|
|
partialArg: string,
|
|
): string[] {
|
|
const skillManager = context.services.config?.getSkillManager();
|
|
if (!skillManager) {
|
|
return [];
|
|
}
|
|
return skillManager
|
|
.getAllSkills()
|
|
.filter((s) => s.disabled && s.name.startsWith(partialArg))
|
|
.map((s) => s.name);
|
|
}
|
|
|
|
export const skillsCommand: SlashCommand = {
|
|
name: 'skills',
|
|
description:
|
|
'List, enable, disable, or reload Gemini CLI agent skills. Usage: /skills [list | disable <name> | enable <name> | reload]',
|
|
kind: CommandKind.BUILT_IN,
|
|
autoExecute: false,
|
|
subCommands: [
|
|
{
|
|
name: 'list',
|
|
description:
|
|
'List available agent skills. Usage: /skills list [nodesc] [all]',
|
|
kind: CommandKind.BUILT_IN,
|
|
action: listAction,
|
|
},
|
|
{
|
|
name: 'link',
|
|
description:
|
|
'Link an agent skill from a local path. Usage: /skills link <path> [--scope user|workspace]',
|
|
kind: CommandKind.BUILT_IN,
|
|
action: linkAction,
|
|
},
|
|
{
|
|
name: 'disable',
|
|
description: 'Disable a skill by name. Usage: /skills disable <name>',
|
|
kind: CommandKind.BUILT_IN,
|
|
action: disableAction,
|
|
completion: disableCompletion,
|
|
},
|
|
{
|
|
name: 'enable',
|
|
description:
|
|
'Enable a disabled skill by name. Usage: /skills enable <name>',
|
|
kind: CommandKind.BUILT_IN,
|
|
action: enableAction,
|
|
completion: enableCompletion,
|
|
},
|
|
{
|
|
name: 'reload',
|
|
altNames: ['refresh'],
|
|
description:
|
|
'Reload the list of discovered skills. Usage: /skills reload',
|
|
kind: CommandKind.BUILT_IN,
|
|
action: reloadAction,
|
|
},
|
|
],
|
|
action: listAction,
|
|
};
|