mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-15 17:03:20 +00:00
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:
@@ -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(
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user