mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-14 16:10:59 -07:00
feat(skills): implement linking for agent skills (#18295)
This commit is contained in:
@@ -17,6 +17,27 @@ import {
|
||||
type MergedSettings,
|
||||
} from '../../config/settings.js';
|
||||
|
||||
vi.mock('../../utils/skillUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../utils/skillUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
linkSkill: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/extensions/consent.js')>();
|
||||
return {
|
||||
...actual,
|
||||
requestConsentInteractive: vi.fn().mockResolvedValue(true),
|
||||
skillsConsentString: vi.fn().mockResolvedValue('Mock Consent'),
|
||||
};
|
||||
});
|
||||
|
||||
import { linkSkill } from '../../utils/skillUtils.js';
|
||||
|
||||
vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/settings.js')>();
|
||||
@@ -185,6 +206,80 @@ describe('skillsCommand', () => {
|
||||
expect(lastCall.skills).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('link', () => {
|
||||
it('should link a skill successfully', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
vi.mocked(linkSkill).mockResolvedValue([
|
||||
{ name: 'test-skill', location: '/path' },
|
||||
]);
|
||||
|
||||
await linkCmd.action!(context, '/some/path');
|
||||
|
||||
expect(linkSkill).toHaveBeenCalledWith(
|
||||
'/some/path',
|
||||
'user',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Successfully linked skills from "/some/path" (user).',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should link a skill with workspace scope', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
vi.mocked(linkSkill).mockResolvedValue([
|
||||
{ name: 'test-skill', location: '/path' },
|
||||
]);
|
||||
|
||||
await linkCmd.action!(context, '/some/path --scope workspace');
|
||||
|
||||
expect(linkSkill).toHaveBeenCalledWith(
|
||||
'/some/path',
|
||||
'workspace',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if link fails', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
vi.mocked(linkSkill).mockRejectedValue(new Error('Link failed'));
|
||||
|
||||
await linkCmd.action!(context, '/some/path');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to link skills: Link failed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if path is missing', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
await linkCmd.action!(context, '');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /skills link <path> [--scope user|workspace]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disable/enable', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
|
||||
@@ -16,10 +16,18 @@ import {
|
||||
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 { renderSkillActionFeedback } from '../../utils/skillUtils.js';
|
||||
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,
|
||||
@@ -68,6 +76,69 @@ async function listAction(
|
||||
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,
|
||||
@@ -301,6 +372,13 @@ export const skillsCommand: SlashCommand = {
|
||||
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>',
|
||||
|
||||
@@ -83,6 +83,12 @@ export interface CommandContext {
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
||||
/**
|
||||
* Sets a confirmation request to be displayed to the user.
|
||||
*
|
||||
* @param value The confirmation request details.
|
||||
*/
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
};
|
||||
|
||||
@@ -237,6 +237,7 @@ export const useSlashCommandProcessor = (
|
||||
dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest:
|
||||
actions.addConfirmUpdateExtensionRequest,
|
||||
setConfirmationRequest,
|
||||
removeComponent: () => setCustomDialog(null),
|
||||
toggleBackgroundShell: actions.toggleBackgroundShell,
|
||||
},
|
||||
@@ -258,6 +259,7 @@ export const useSlashCommandProcessor = (
|
||||
actions,
|
||||
pendingItem,
|
||||
setPendingItem,
|
||||
setConfirmationRequest,
|
||||
toggleVimEnabled,
|
||||
sessionShellAllowlist,
|
||||
reloadCommands,
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
extensionsUpdateState: new Map(),
|
||||
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||
setConfirmationRequest: (_request) => {},
|
||||
removeComponent: () => {},
|
||||
toggleBackgroundShell: () => {},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user