fix(providers): guard invalid skill command namespaces

Claude plugin ids come from local settings and installed plugin metadata.

Invalid ids such as empty strings or @ should not become command namespaces.

Skip plugin folders when no safe plugin name can be derived.

This prevents malformed slash commands like /:command from reaching the UI.

Add regression coverage for empty and @ plugin ids.

Keyboard selection in the slash menu should match mouse selection.

Only skills are inserted into the composer because they are provider invocations.

Built-in and custom commands execute directly and close the menu on success or failure.
This commit is contained in:
Haileyesus
2026-05-11 18:58:55 +03:00
parent 053e43447a
commit aabf331e91
3 changed files with 85 additions and 26 deletions

View File

@@ -20,9 +20,14 @@ import {
const getClaudeHomePath = (): string => path.join(os.homedir(), '.claude'); const getClaudeHomePath = (): string => path.join(os.homedir(), '.claude');
const getClaudePluginName = (pluginId: string): string => { const getClaudePluginName = (pluginId: string): string | null => {
const [pluginName] = pluginId.split('@'); const normalizedPluginId = pluginId.trim();
return pluginName || pluginId; if (!normalizedPluginId || normalizedPluginId === '@') {
return null;
}
const [pluginName] = normalizedPluginId.split('@');
return readOptionalString(pluginName) ?? null;
}; };
const stripMarkdownExtension = (filename: string): string => const stripMarkdownExtension = (filename: string): string =>
@@ -52,7 +57,7 @@ const listChildDirectories = async (directoryPath: string): Promise<string[]> =>
const readClaudePluginName = async ( const readClaudePluginName = async (
installPath: string, installPath: string,
pluginId: string, pluginId: string,
): Promise<string> => { ): Promise<string | null> => {
try { try {
const pluginConfig = await readJsonConfig( const pluginConfig = await readJsonConfig(
path.join(installPath, '.claude-plugin', 'plugin.json'), path.join(installPath, '.claude-plugin', 'plugin.json'),
@@ -142,6 +147,10 @@ export class ClaudeSkillsProvider extends SkillsProvider {
visitedPluginFolders.add(pluginFolderKey); visitedPluginFolders.add(pluginFolderKey);
const pluginName = await readClaudePluginName(pluginFolder, pluginId); const pluginName = await readClaudePluginName(pluginFolder, pluginId);
if (!pluginName) {
continue;
}
const commandsPath = path.join(pluginFolder, 'commands'); const commandsPath = path.join(pluginFolder, 'commands');
if (await pathExistsAsDirectory(commandsPath)) { if (await pathExistsAsDirectory(commandsPath)) {
skills.push( skills.push(

View File

@@ -101,6 +101,24 @@ test('providerSkillsService lists claude user, project, and enabled plugin skill
'disabled-skills', 'disabled-skills',
'ghi789', 'ghi789',
); );
const emptyIdPluginInstallPath = path.join(
tempRoot,
'.claude',
'plugins',
'cache',
'invalid-empty-plugin',
'empty',
'000',
);
const atIdPluginInstallPath = path.join(
tempRoot,
'.claude',
'plugins',
'cache',
'invalid-at-plugin',
'at',
'000',
);
const siblingSkillPluginPath = path.join(path.dirname(skillPluginInstallPath), 'legacy777'); const siblingSkillPluginPath = path.join(path.dirname(skillPluginInstallPath), 'legacy777');
await fs.mkdir(workspacePath, { recursive: true }); await fs.mkdir(workspacePath, { recursive: true });
@@ -161,6 +179,16 @@ test('providerSkillsService lists claude user, project, and enabled plugin skill
'disabled-command', 'disabled-command',
'Disabled plugin command', 'Disabled plugin command',
); );
await writeClaudePluginCommand(
path.join(emptyIdPluginInstallPath, 'commands'),
'invalid-empty-command',
'Invalid empty id command',
);
await writeClaudePluginCommand(
path.join(atIdPluginInstallPath, 'commands'),
'invalid-at-command',
'Invalid at id command',
);
await writeSkill( await writeSkill(
path.join( path.join(
disabledPluginInstallPath, disabledPluginInstallPath,
@@ -176,6 +204,8 @@ test('providerSkillsService lists claude user, project, and enabled plugin skill
JSON.stringify( JSON.stringify(
{ {
enabledPlugins: { enabledPlugins: {
'': true,
'@': true,
'notion@notion-marketplace': true, 'notion@notion-marketplace': true,
'example-skills@anthropic-agent-skills': true, 'example-skills@anthropic-agent-skills': true,
'disabled-skills@disabled-marketplace': false, 'disabled-skills@disabled-marketplace': false,
@@ -192,6 +222,20 @@ test('providerSkillsService lists claude user, project, and enabled plugin skill
{ {
version: 2, version: 2,
plugins: { plugins: {
'': [
{
scope: 'user',
installPath: emptyIdPluginInstallPath,
version: '000',
},
],
'@': [
{
scope: 'user',
installPath: atIdPluginInstallPath,
version: '000',
},
],
'notion@notion-marketplace': [ 'notion@notion-marketplace': [
{ {
scope: 'user', scope: 'user',
@@ -265,6 +309,9 @@ test('providerSkillsService lists claude user, project, and enabled plugin skill
assert.equal(siblingPluginSkill?.description, 'Sibling Claude plugin skill'); assert.equal(siblingPluginSkill?.description, 'Sibling Claude plugin skill');
assert.equal(byName.has('disabled-command'), false); assert.equal(byName.has('disabled-command'), false);
assert.equal(byName.has('disabled-plugin'), false); assert.equal(byName.has('disabled-plugin'), false);
assert.equal(byName.has('invalid-empty-command'), false);
assert.equal(byName.has('invalid-at-command'), false);
assert.equal(skills.some((skill) => skill.command.startsWith('/:')), false);
} finally { } finally {
restoreHomeDir(); restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true }); await fs.rm(tempRoot, { recursive: true, force: true });

View File

@@ -310,22 +310,36 @@ export function useSlashCommands({
[input, resetCommandMenuState, setInput, slashPosition, textareaRef], [input, resetCommandMenuState, setInput, slashPosition, textareaRef],
); );
const executeNonSkillCommand = useCallback(
(command: SlashCommand) => {
const executionResult = onExecuteCommand(command);
if (isPromiseLike(executionResult)) {
executionResult.then(
() => {
resetCommandMenuState();
},
() => {
resetCommandMenuState();
// Keep behavior silent; execution errors are handled by caller.
},
);
} else {
resetCommandMenuState();
}
},
[onExecuteCommand, resetCommandMenuState],
);
const selectCommandFromKeyboard = useCallback( const selectCommandFromKeyboard = useCallback(
(command: SlashCommand) => { (command: SlashCommand) => {
insertCommandIntoInput(command);
if (isSkillCommand(command)) { if (isSkillCommand(command)) {
insertCommandIntoInput(command);
return; return;
} }
const executionResult = onExecuteCommand(command); executeNonSkillCommand(command);
if (isPromiseLike(executionResult)) {
executionResult.catch(() => {
// Keep behavior silent; execution errors are handled by caller.
});
}
}, },
[insertCommandIntoInput, onExecuteCommand], [executeNonSkillCommand, insertCommandIntoInput],
); );
const handleCommandSelect = useCallback( const handleCommandSelect = useCallback(
@@ -345,20 +359,9 @@ export function useSlashCommands({
return; return;
} }
const executionResult = onExecuteCommand(command); executeNonSkillCommand(command);
if (isPromiseLike(executionResult)) {
executionResult.then(() => {
resetCommandMenuState();
});
executionResult.catch(() => {
// Keep behavior silent; execution errors are handled by caller.
});
} else {
resetCommandMenuState();
}
}, },
[selectedProject, trackCommandUsage, insertCommandIntoInput, onExecuteCommand, resetCommandMenuState], [selectedProject, trackCommandUsage, insertCommandIntoInput, executeNonSkillCommand],
); );
const handleToggleCommandMenu = useCallback(() => { const handleToggleCommandMenu = useCallback(() => {