[Skills] Foundation: Centralize management logic and feedback rendering (#15952)

This commit is contained in:
N. Taylor Mullen
2026-01-06 20:11:19 -08:00
committed by GitHub
parent 982eee63b6
commit a26463b056
8 changed files with 275 additions and 70 deletions

View File

@@ -36,6 +36,7 @@ vi.mock('../../config/settings.js', async (importOriginal) => {
return {
...actual,
loadSettings: vi.fn(),
isLoadableSettingScope: vi.fn((s) => s === 'User' || s === 'Workspace'),
};
});
@@ -78,7 +79,7 @@ describe('skills disable command', () => {
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Skill "skill1" successfully disabled in scope "User".',
'Skill "skill1" disabled by adding it to the disabled list in user settings.',
);
});
@@ -86,22 +87,20 @@ describe('skills disable command', () => {
const mockSettings = {
forScope: vi.fn().mockReturnValue({
settings: { skills: { disabled: ['skill1'] } },
path: '/user/settings.json',
}),
setValue: vi.fn(),
};
mockLoadSettings.mockReturnValue(
vi.mocked(loadSettings).mockReturnValue(
mockSettings as unknown as LoadedSettings,
);
await handleDisable({
name: 'skill1',
scope: SettingScope.User as LoadableSettingScope,
});
await handleDisable({ name: 'skill1', scope: SettingScope.User });
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Skill "skill1" is already disabled in scope "User".',
'Skill "skill1" is already disabled.',
);
});
});

View File

@@ -12,6 +12,8 @@ import {
} from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import { disableSkill } from '../../utils/skillSettings.js';
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
interface DisableArgs {
name: string;
@@ -23,17 +25,8 @@ export async function handleDisable(args: DisableArgs) {
const workspaceDir = process.cwd();
const settings = loadSettings(workspaceDir);
const currentDisabled =
settings.forScope(scope).settings.skills?.disabled || [];
if (currentDisabled.includes(name)) {
debugLogger.log(`Skill "${name}" is already disabled in scope "${scope}".`);
return;
}
const newDisabled = [...currentDisabled, name];
settings.setValue(scope, 'skills.disabled', newDisabled);
debugLogger.log(`Skill "${name}" successfully disabled in scope "${scope}".`);
const result = disableSkill(settings, name, scope);
debugLogger.log(renderSkillActionFeedback(result, (label, _path) => label));
}
export const disableCommand: CommandModule = {

View File

@@ -36,6 +36,7 @@ vi.mock('../../config/settings.js', async (importOriginal) => {
return {
...actual,
loadSettings: vi.fn(),
isLoadableSettingScope: vi.fn((s) => s === 'User' || s === 'Workspace'),
};
});
@@ -78,7 +79,7 @@ describe('skills enable command', () => {
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Skill "skill1" successfully enabled in scope "User".',
'Skill "skill1" enabled by removing it from the disabled list in user settings.',
);
});
@@ -86,22 +87,20 @@ describe('skills enable command', () => {
const mockSettings = {
forScope: vi.fn().mockReturnValue({
settings: { skills: { disabled: [] } },
path: '/user/settings.json',
}),
setValue: vi.fn(),
};
mockLoadSettings.mockReturnValue(
vi.mocked(loadSettings).mockReturnValue(
mockSettings as unknown as LoadedSettings,
);
await handleEnable({
name: 'skill1',
scope: SettingScope.User as LoadableSettingScope,
});
await handleEnable({ name: 'skill1', scope: SettingScope.User });
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Skill "skill1" is already enabled in scope "User".',
'Skill "skill1" is already enabled.',
);
});
});

View File

@@ -12,6 +12,8 @@ import {
} from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import { enableSkill } from '../../utils/skillSettings.js';
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
interface EnableArgs {
name: string;
@@ -23,17 +25,8 @@ export async function handleEnable(args: EnableArgs) {
const workspaceDir = process.cwd();
const settings = loadSettings(workspaceDir);
const currentDisabled =
settings.forScope(scope).settings.skills?.disabled || [];
const newDisabled = currentDisabled.filter((d) => d !== name);
if (currentDisabled.length === newDisabled.length) {
debugLogger.log(`Skill "${name}" is already enabled in scope "${scope}".`);
return;
}
settings.setValue(scope, 'skills.disabled', newDisabled);
debugLogger.log(`Skill "${name}" successfully enabled in scope "${scope}".`);
const result = enableSkill(settings, name, scope);
debugLogger.log(renderSkillActionFeedback(result, (label, _path) => label));
}
export const enableCommand: CommandModule = {

View File

@@ -12,6 +12,15 @@ import type { CommandContext } from './types.js';
import type { Config, SkillDefinition } from '@google/gemini-cli-core';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
vi.mock('../../config/settings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/settings.js')>();
return {
...actual,
isLoadableSettingScope: vi.fn((s) => s === 'User' || s === 'Workspace'),
};
});
describe('skillsCommand', () => {
let context: CommandContext;
@@ -135,6 +144,28 @@ describe('skillsCommand', () => {
).workspace = {
path: '/workspace',
};
interface MockSettings {
user: { settings: unknown; path: string };
workspace: { settings: unknown; path: string };
forScope: unknown;
}
const settings = context.services.settings as unknown as MockSettings;
settings.forScope = vi.fn((scope) => {
if (scope === SettingScope.User) return settings.user;
if (scope === SettingScope.Workspace) return settings.workspace;
return { settings: {}, path: '' };
});
settings.user = {
settings: {},
path: '/user/settings.json',
};
settings.workspace = {
settings: {},
path: '/workspace',
};
});
it('should disable a skill', async () => {
@@ -151,7 +182,7 @@ describe('skillsCommand', () => {
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Skill "skill1" disabled'),
text: 'Skill "skill1" disabled by adding it to the disabled list in project settings. Use "/skills reload" for it to take effect.',
}),
expect.any(Number),
);
@@ -162,6 +193,15 @@ describe('skillsCommand', () => {
(s) => s.name === 'enable',
)!;
context.services.settings.merged.skills = { disabled: ['skill1'] };
// Also need to mock the scope-specific disabled list
(
context.services.settings as unknown as {
workspace: { settings: { skills: { disabled: string[] } } };
}
).workspace.settings = {
skills: { disabled: ['skill1'] },
};
await enableCmd.action!(context, 'skill1');
expect(context.services.settings.setValue).toHaveBeenCalledWith(
@@ -172,7 +212,7 @@ describe('skillsCommand', () => {
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Skill "skill1" enabled'),
text: 'Skill "skill1" enabled by removing it from the disabled list in project settings. Use "/skills reload" for it to take effect.',
}),
expect.any(Number),
);

View File

@@ -16,6 +16,8 @@ import {
type HistoryItemInfo,
} from '../types.js';
import { SettingScope } from '../../config/settings.js';
import { enableSkill, disableSkill } from '../../utils/skillSettings.js';
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
async function listAction(
context: CommandContext,
@@ -86,29 +88,24 @@ async function disableAction(
return;
}
const currentDisabled =
context.services.settings.merged.skills?.disabled ?? [];
if (currentDisabled.includes(skillName)) {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Skill "${skillName}" is already disabled.`,
},
Date.now(),
);
return;
}
const newDisabled = [...currentDisabled, skillName];
const scope = context.services.settings.workspace.path
? SettingScope.Workspace
: SettingScope.User;
context.services.settings.setValue(scope, 'skills.disabled', newDisabled);
const result = disableSkill(context.services.settings, skillName, scope);
let feedback = renderSkillActionFeedback(
result,
(label, _path) => `${label}`,
);
if (result.status === 'success') {
feedback += ' Use "/skills reload" for it to take effect.';
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Skill "${skillName}" disabled in ${scope} settings. Use "/skills reload" for it to take effect.`,
text: feedback,
},
Date.now(),
);
@@ -130,29 +127,24 @@ async function enableAction(
return;
}
const currentDisabled =
context.services.settings.merged.skills?.disabled ?? [];
if (!currentDisabled.includes(skillName)) {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Skill "${skillName}" is not disabled.`,
},
Date.now(),
);
return;
}
const newDisabled = currentDisabled.filter((name) => name !== skillName);
const scope = context.services.settings.workspace.path
? SettingScope.Workspace
: SettingScope.User;
context.services.settings.setValue(scope, 'skills.disabled', newDisabled);
const result = enableSkill(context.services.settings, skillName, scope);
let feedback = renderSkillActionFeedback(
result,
(label, _path) => `${label}`,
);
if (result.status === 'success') {
feedback += ' Use "/skills reload" for it to take effect.';
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Skill "${skillName}" enabled in ${scope} settings. Use "/skills reload" for it to take effect.`,
text: feedback,
},
Date.now(),
);

View File

@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
isLoadableSettingScope,
type SettingScope,
type LoadedSettings,
} from '../config/settings.js';
export interface ModifiedScope {
scope: SettingScope;
path: string;
}
export type SkillActionStatus = 'success' | 'no-op' | 'error';
/**
* Metadata representing the result of a skill settings operation.
*/
export interface SkillActionResult {
status: SkillActionStatus;
skillName: string;
action: 'enable' | 'disable';
/** Scopes where the skill's state was actually changed. */
modifiedScopes: ModifiedScope[];
/** Scopes where the skill was already in the desired state. */
alreadyInStateScopes: ModifiedScope[];
/** Error message if status is 'error'. */
error?: string;
}
/**
* Enables a skill by removing it from the specified disabled list.
*/
export function enableSkill(
settings: LoadedSettings,
skillName: string,
scope: SettingScope,
): SkillActionResult {
if (!isLoadableSettingScope(scope)) {
return {
status: 'error',
skillName,
action: 'enable',
modifiedScopes: [],
alreadyInStateScopes: [],
error: `Invalid settings scope: ${scope}`,
};
}
const scopePath = settings.forScope(scope).path;
const currentScopeDisabled =
settings.forScope(scope).settings.skills?.disabled ?? [];
if (!currentScopeDisabled.includes(skillName)) {
return {
status: 'no-op',
skillName,
action: 'enable',
modifiedScopes: [],
alreadyInStateScopes: [{ scope, path: scopePath }],
};
}
const newDisabled = currentScopeDisabled.filter((name) => name !== skillName);
settings.setValue(scope, 'skills.disabled', newDisabled);
return {
status: 'success',
skillName,
action: 'enable',
modifiedScopes: [{ scope, path: scopePath }],
alreadyInStateScopes: [],
};
}
/**
* Disables a skill by adding it to the disabled list in the specified scope.
*/
export function disableSkill(
settings: LoadedSettings,
skillName: string,
scope: SettingScope,
): SkillActionResult {
if (!isLoadableSettingScope(scope)) {
return {
status: 'error',
skillName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [],
error: `Invalid settings scope: ${scope}`,
};
}
const scopePath = settings.forScope(scope).path;
const currentScopeDisabled =
settings.forScope(scope).settings.skills?.disabled ?? [];
if (currentScopeDisabled.includes(skillName)) {
return {
status: 'no-op',
skillName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [{ scope, path: scopePath }],
};
}
const newDisabled = [...currentScopeDisabled, skillName];
settings.setValue(scope, 'skills.disabled', newDisabled);
return {
status: 'success',
skillName,
action: 'disable',
modifiedScopes: [{ scope, path: scopePath }],
alreadyInStateScopes: [],
};
}

View File

@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { SettingScope } from '../config/settings.js';
import type { SkillActionResult } from './skillSettings.js';
/**
* Shared logic for building the core skill action message while allowing the
* caller to control how each scope and its path are rendered (e.g., bolding or
* dimming).
*
* This function ONLY returns the description of what happened. It is up to the
* caller to append any interface-specific guidance (like "Use /skills reload"
* or "Restart required").
*/
export function renderSkillActionFeedback(
result: SkillActionResult,
formatScope: (label: string, path: string) => string,
): string {
const { skillName, action, status, error } = result;
if (status === 'error') {
return (
error ||
`An error occurred while attempting to ${action} skill "${skillName}".`
);
}
if (status === 'no-op') {
return `Skill "${skillName}" is already ${action === 'enable' ? 'enabled' : 'disabled'}.`;
}
const isEnable = action === 'enable';
const actionVerb = isEnable ? 'enabled' : 'disabled';
const preposition = isEnable
? 'by removing it from the disabled list in'
: 'by adding it to the disabled list in';
const formatScopeItem = (s: { scope: SettingScope; path: string }) => {
const label =
s.scope === SettingScope.Workspace ? 'project' : s.scope.toLowerCase();
return formatScope(label, s.path);
};
const totalAffectedScopes = [
...result.modifiedScopes,
...result.alreadyInStateScopes,
];
if (totalAffectedScopes.length === 2) {
const s1 = formatScopeItem(totalAffectedScopes[0]);
const s2 = formatScopeItem(totalAffectedScopes[1]);
if (isEnable) {
return `Skill "${skillName}" ${actionVerb} ${preposition} ${s1} and ${s2} settings.`;
} else {
return `Skill "${skillName}" is now disabled in both ${s1} and ${s2} settings.`;
}
}
const s = formatScopeItem(totalAffectedScopes[0]);
return `Skill "${skillName}" ${actionVerb} ${preposition} ${s} settings.`;
}