mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 00:21:09 -07:00
Agent Skills: Implement /skills reload (#15865)
This commit is contained in:
@@ -188,6 +188,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
|
||||
useState<boolean>(false);
|
||||
const [historyRemountKey, setHistoryRemountKey] = useState(0);
|
||||
const [settingsNonce, setSettingsNonce] = useState(0);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
|
||||
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
||||
isWorkspaceTrusted(settings.merged).isTrusted,
|
||||
@@ -368,6 +369,17 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSettingsChanged = () => {
|
||||
setSettingsNonce((prev) => prev + 1);
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
|
||||
useConsoleMessages();
|
||||
|
||||
@@ -1546,6 +1558,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerData,
|
||||
bannerVisible,
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
settingsNonce,
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
@@ -1638,6 +1651,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerData,
|
||||
bannerVisible,
|
||||
config,
|
||||
settingsNonce,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { skillsCommand } from './skillsCommand.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import type { Config, SkillDefinition } from '@google/gemini-cli-core';
|
||||
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
describe('skillsCommand', () => {
|
||||
let context: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
const skills = [
|
||||
{
|
||||
name: 'skill1',
|
||||
@@ -35,6 +36,7 @@ describe('skillsCommand', () => {
|
||||
config: {
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
getAllSkills: vi.fn().mockReturnValue(skills),
|
||||
getSkills: vi.fn().mockReturnValue(skills),
|
||||
getSkill: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
@@ -51,6 +53,11 @@ describe('skillsCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should add a SKILLS_LIST item to UI with descriptions by default', async () => {
|
||||
await skillsCommand.action!(context, '');
|
||||
|
||||
@@ -187,6 +194,170 @@ describe('skillsCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reload', () => {
|
||||
it('should reload skills successfully and show success message', async () => {
|
||||
const reloadCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'reload',
|
||||
)!;
|
||||
// Make reload take some time so timer can fire
|
||||
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
});
|
||||
// @ts-expect-error Mocking reloadSkills
|
||||
context.services.config.reloadSkills = reloadSkillsMock;
|
||||
|
||||
const actionPromise = reloadCmd.action!(context, '');
|
||||
|
||||
// Initially, no pending item (flicker prevention)
|
||||
expect(context.ui.setPendingItem).not.toHaveBeenCalled();
|
||||
|
||||
// Fast forward 100ms to trigger the pending item
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(context.ui.setPendingItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Reloading agent skills...',
|
||||
}),
|
||||
);
|
||||
|
||||
// Fast forward another 100ms (reload complete), but pending item should stay
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(context.ui.setPendingItem).not.toHaveBeenCalledWith(null);
|
||||
|
||||
// Fast forward to reach 500ms total
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await actionPromise;
|
||||
|
||||
expect(reloadSkillsMock).toHaveBeenCalled();
|
||||
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Agent skills reloaded successfully.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show new skills count after reload', async () => {
|
||||
const reloadCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'reload',
|
||||
)!;
|
||||
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
|
||||
const skillManager = context.services.config!.getSkillManager();
|
||||
vi.mocked(skillManager.getSkills).mockReturnValue([
|
||||
{ name: 'skill1' },
|
||||
{ name: 'skill2' },
|
||||
{ name: 'skill3' },
|
||||
] as SkillDefinition[]);
|
||||
});
|
||||
// @ts-expect-error Mocking reloadSkills
|
||||
context.services.config.reloadSkills = reloadSkillsMock;
|
||||
|
||||
await reloadCmd.action!(context, '');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Agent skills reloaded successfully. 1 newly available skill.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show removed skills count after reload', async () => {
|
||||
const reloadCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'reload',
|
||||
)!;
|
||||
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
|
||||
const skillManager = context.services.config!.getSkillManager();
|
||||
vi.mocked(skillManager.getSkills).mockReturnValue([
|
||||
{ name: 'skill1' },
|
||||
] as SkillDefinition[]);
|
||||
});
|
||||
// @ts-expect-error Mocking reloadSkills
|
||||
context.services.config.reloadSkills = reloadSkillsMock;
|
||||
|
||||
await reloadCmd.action!(context, '');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Agent skills reloaded successfully. 1 skill no longer available.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show both added and removed skills count after reload', async () => {
|
||||
const reloadCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'reload',
|
||||
)!;
|
||||
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
|
||||
const skillManager = context.services.config!.getSkillManager();
|
||||
vi.mocked(skillManager.getSkills).mockReturnValue([
|
||||
{ name: 'skill2' }, // skill1 removed, skill3 added
|
||||
{ name: 'skill3' },
|
||||
] as SkillDefinition[]);
|
||||
});
|
||||
// @ts-expect-error Mocking reloadSkills
|
||||
context.services.config.reloadSkills = reloadSkillsMock;
|
||||
|
||||
await reloadCmd.action!(context, '');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Agent skills reloaded successfully. 1 newly available skill and 1 skill no longer available.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if configuration is missing', async () => {
|
||||
const reloadCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'reload',
|
||||
)!;
|
||||
context.services.config = null;
|
||||
|
||||
await reloadCmd.action!(context, '');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Could not retrieve configuration.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if reload fails', async () => {
|
||||
const reloadCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'reload',
|
||||
)!;
|
||||
const error = new Error('Reload failed');
|
||||
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
|
||||
await new Promise((_, reject) => setTimeout(() => reject(error), 200));
|
||||
});
|
||||
// @ts-expect-error Mocking reloadSkills
|
||||
context.services.config.reloadSkills = reloadSkillsMock;
|
||||
|
||||
const actionPromise = reloadCmd.action!(context, '');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
await actionPromise;
|
||||
|
||||
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to reload skills: Reload failed',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completions', () => {
|
||||
it('should provide completions for disable (only enabled skills)', async () => {
|
||||
const disableCmd = skillsCommand.subCommands!.find(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
type SlashCommandActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType, type HistoryItemSkillsList } from '../types.js';
|
||||
import {
|
||||
MessageType,
|
||||
type HistoryItemSkillsList,
|
||||
type HistoryItemInfo,
|
||||
} from '../types.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
async function listAction(
|
||||
@@ -104,7 +108,7 @@ async function disableAction(
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Skill "${skillName}" disabled in ${scope} settings. Restart required to take effect.`,
|
||||
text: `Skill "${skillName}" disabled in ${scope} settings. Use "/skills reload" for it to take effect.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -148,12 +152,107 @@ async function enableAction(
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Skill "${skillName}" enabled in ${scope} settings. Restart required to take effect.`,
|
||||
text: `Skill "${skillName}" enabled in ${scope} settings. Use "/skills reload" for it to take effect.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
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.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
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,
|
||||
Date.now(),
|
||||
);
|
||||
} 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)}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function disableCompletion(
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
@@ -185,7 +284,7 @@ function enableCompletion(
|
||||
export const skillsCommand: SlashCommand = {
|
||||
name: 'skills',
|
||||
description:
|
||||
'List, enable, or disable Gemini CLI agent skills. Usage: /skills [list | disable <name> | enable <name>]',
|
||||
'List, enable, disable, or reload Gemini CLI agent skills. Usage: /skills [list | disable <name> | enable <name> | reload]',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands: [
|
||||
@@ -210,6 +309,13 @@ export const skillsCommand: SlashCommand = {
|
||||
action: enableAction,
|
||||
completion: enableCompletion,
|
||||
},
|
||||
{
|
||||
name: 'reload',
|
||||
description:
|
||||
'Reload the list of discovered skills. Usage: /skills reload',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: reloadAction,
|
||||
},
|
||||
],
|
||||
action: listAction,
|
||||
};
|
||||
|
||||
@@ -138,6 +138,7 @@ export interface UIState {
|
||||
bannerVisible: boolean;
|
||||
customDialog: React.ReactNode | null;
|
||||
terminalBackgroundColor: TerminalBackgroundColor;
|
||||
settingsNonce: number;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
Reference in New Issue
Block a user