mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-14 08:44:29 +00:00
* feat(providers): surface skills in slash command menu Provider skills were hidden behind provider-specific filesystem rules. That made the backend and UI unable to offer one discovery path for skills. Add a normalized skills contract, provider service, and provider skills API. Keep provider-specific lookup rules inside adapters so routes and UI stay generic. Claude needs plugin handling because enabled plugins resolve through installed_plugins.json. Plugin folders can expose commands or skills, so Claude scans both forms. Claude plugin commands are namespaced to avoid collisions with user and project skills. Codex, Gemini, and Cursor adapters map their expected skill roots into the same contract. The slash menu now shows skills beside built-in and custom commands for discovery. The menu avoids mid-message activation, duplicate rows, loose namespace matches, and input overlap. Provider tests cover discovery locations and Claude plugin edge cases. * 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. * fix(security): centralize safe frontmatter parsing Move frontmatter parsing into server/shared/frontmatter.ts so every backend caller uses the same gray-matter configuration instead of importing gray-matter directly. The goal is to keep executable JS and JSON frontmatter engines disabled for all markdown discovered from the filesystem, not only command routes. Provider skills and shared skill metadata now go through parseFrontMatter too. That closes the gap where plugin or provider markdown could regain default gray-matter behavior simply because it lived outside the original command path. Classify the new parser in backend boundaries so modules can depend on the safe shared API without reaching into legacy utility paths. * feat(providers): add comprehensive guide for provider module setup and usage
447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
|
|
|
const patchHomeDir = (nextHomeDir: string) => {
|
|
const original = os.homedir;
|
|
(os as any).homedir = () => nextHomeDir;
|
|
return () => {
|
|
(os as any).homedir = original;
|
|
};
|
|
};
|
|
|
|
const writeSkill = async (
|
|
skillsRoot: string,
|
|
directoryName: string,
|
|
name: string,
|
|
description: string,
|
|
): Promise<string> => {
|
|
const skillDir = path.join(skillsRoot, directoryName);
|
|
await fs.mkdir(skillDir, { recursive: true });
|
|
const skillPath = path.join(skillDir, 'SKILL.md');
|
|
await fs.writeFile(
|
|
skillPath,
|
|
`---\nname: ${name}\ndescription: ${description}\n---\n\n`,
|
|
'utf8',
|
|
);
|
|
return skillPath;
|
|
};
|
|
|
|
const writeClaudePluginManifest = async (
|
|
installPath: string,
|
|
name: string,
|
|
): Promise<void> => {
|
|
const pluginConfigDir = path.join(installPath, '.claude-plugin');
|
|
await fs.mkdir(pluginConfigDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(pluginConfigDir, 'plugin.json'),
|
|
JSON.stringify(
|
|
{
|
|
name,
|
|
version: '0.1.0',
|
|
description: `${name} test plugin`,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
'utf8',
|
|
);
|
|
};
|
|
|
|
const writeClaudePluginCommand = async (
|
|
commandsRoot: string,
|
|
commandName: string,
|
|
description: string,
|
|
): Promise<string> => {
|
|
await fs.mkdir(commandsRoot, { recursive: true });
|
|
const commandPath = path.join(commandsRoot, `${commandName}.md`);
|
|
await fs.writeFile(
|
|
commandPath,
|
|
`---\ndescription: ${description}\nargument-hint: 'test args'\n---\n\nCommand body.\n`,
|
|
'utf8',
|
|
);
|
|
return commandPath;
|
|
};
|
|
|
|
/**
|
|
* This test covers Claude user/project skill folders plus plugin discovery from
|
|
* installed plugin command files and fallback plugin skill files.
|
|
*/
|
|
test('providerSkillsService lists claude user, project, and enabled plugin skills', { concurrency: false }, async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-claude-'));
|
|
const workspacePath = path.join(tempRoot, 'workspace');
|
|
const commandPluginInstallPath = path.join(
|
|
tempRoot,
|
|
'.claude',
|
|
'plugins',
|
|
'cache',
|
|
'notion-plugin',
|
|
'notion',
|
|
'abc123',
|
|
);
|
|
const skillPluginInstallPath = path.join(
|
|
tempRoot,
|
|
'.claude',
|
|
'plugins',
|
|
'cache',
|
|
'anthropic-agent-skills',
|
|
'example-skills',
|
|
'def456',
|
|
);
|
|
const disabledPluginInstallPath = path.join(
|
|
tempRoot,
|
|
'.claude',
|
|
'plugins',
|
|
'cache',
|
|
'disabled-marketplace',
|
|
'disabled-skills',
|
|
'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');
|
|
await fs.mkdir(workspacePath, { recursive: true });
|
|
|
|
const restoreHomeDir = patchHomeDir(tempRoot);
|
|
try {
|
|
await writeSkill(
|
|
path.join(tempRoot, '.claude', 'skills'),
|
|
'claude-user-dir',
|
|
'claude-user',
|
|
'Claude user skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(workspacePath, '.claude', 'skills'),
|
|
'claude-project-dir',
|
|
'claude-project',
|
|
'Claude project skill',
|
|
);
|
|
await writeClaudePluginManifest(commandPluginInstallPath, 'Notion');
|
|
await writeClaudePluginCommand(
|
|
path.join(commandPluginInstallPath, 'commands'),
|
|
'insert-row',
|
|
'Insert a Notion database row',
|
|
);
|
|
await writeSkill(
|
|
path.join(commandPluginInstallPath, 'skills'),
|
|
'ignored-command-plugin-skill-dir',
|
|
'ignored-command-plugin-skill',
|
|
'Command plugin fallback skill should be ignored',
|
|
);
|
|
await writeClaudePluginManifest(skillPluginInstallPath, 'ExampleSkills');
|
|
await writeSkill(
|
|
path.join(skillPluginInstallPath, 'skills'),
|
|
'claude-plugin-dir',
|
|
'claude-plugin',
|
|
'Claude plugin skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(skillPluginInstallPath, 'skills'),
|
|
'claude-plugin-second-dir',
|
|
'claude-plugin-second',
|
|
'Second Claude plugin skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(skillPluginInstallPath, 'skills', 'nested', 'collection'),
|
|
'claude-plugin-nested-dir',
|
|
'claude-plugin-nested',
|
|
'Nested Claude plugin skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(siblingSkillPluginPath, 'skills'),
|
|
'claude-plugin-sibling-dir',
|
|
'claude-plugin-sibling',
|
|
'Sibling Claude plugin skill',
|
|
);
|
|
await writeClaudePluginManifest(disabledPluginInstallPath, 'DisabledSkills');
|
|
await writeClaudePluginCommand(
|
|
path.join(disabledPluginInstallPath, 'commands'),
|
|
'disabled-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(
|
|
path.join(
|
|
disabledPluginInstallPath,
|
|
'skills',
|
|
),
|
|
'disabled-plugin-dir',
|
|
'disabled-plugin',
|
|
'Disabled plugin skill',
|
|
);
|
|
|
|
await fs.writeFile(
|
|
path.join(tempRoot, '.claude', 'settings.json'),
|
|
JSON.stringify(
|
|
{
|
|
enabledPlugins: {
|
|
'': true,
|
|
'@': true,
|
|
'notion@notion-marketplace': true,
|
|
'example-skills@anthropic-agent-skills': true,
|
|
'disabled-skills@disabled-marketplace': false,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
'utf8',
|
|
);
|
|
await fs.writeFile(
|
|
path.join(tempRoot, '.claude', 'plugins', 'installed_plugins.json'),
|
|
JSON.stringify(
|
|
{
|
|
version: 2,
|
|
plugins: {
|
|
'': [
|
|
{
|
|
scope: 'user',
|
|
installPath: emptyIdPluginInstallPath,
|
|
version: '000',
|
|
},
|
|
],
|
|
'@': [
|
|
{
|
|
scope: 'user',
|
|
installPath: atIdPluginInstallPath,
|
|
version: '000',
|
|
},
|
|
],
|
|
'notion@notion-marketplace': [
|
|
{
|
|
scope: 'user',
|
|
installPath: commandPluginInstallPath,
|
|
version: 'abc123',
|
|
},
|
|
],
|
|
'example-skills@anthropic-agent-skills': [
|
|
{
|
|
scope: 'user',
|
|
installPath: skillPluginInstallPath,
|
|
version: 'def456',
|
|
},
|
|
],
|
|
'disabled-skills@disabled-marketplace': [
|
|
{
|
|
scope: 'user',
|
|
installPath: disabledPluginInstallPath,
|
|
version: 'ghi789',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
'utf8',
|
|
);
|
|
|
|
const skills = await providerSkillsService.listProviderSkills('claude', { workspacePath });
|
|
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
|
|
|
assert.equal(byName.get('claude-user')?.scope, 'user');
|
|
assert.equal(byName.get('claude-user')?.command, '/claude-user');
|
|
assert.equal(byName.get('claude-project')?.scope, 'project');
|
|
assert.equal(byName.get('claude-project')?.command, '/claude-project');
|
|
|
|
const pluginCommand = byName.get('insert-row');
|
|
assert.equal(pluginCommand?.scope, 'plugin');
|
|
assert.equal(pluginCommand?.pluginName, 'Notion');
|
|
assert.equal(pluginCommand?.pluginId, 'notion@notion-marketplace');
|
|
assert.equal(pluginCommand?.command, '/Notion:insert-row');
|
|
assert.equal(pluginCommand?.description, 'Insert a Notion database row');
|
|
assert.match(pluginCommand?.sourcePath ?? '', /commands[\\/]insert-row\.md$/);
|
|
assert.equal(byName.has('ignored-command-plugin-skill'), false);
|
|
|
|
const pluginSkill = byName.get('claude-plugin');
|
|
assert.equal(pluginSkill?.scope, 'plugin');
|
|
assert.equal(pluginSkill?.pluginName, 'ExampleSkills');
|
|
assert.equal(pluginSkill?.pluginId, 'example-skills@anthropic-agent-skills');
|
|
assert.equal(pluginSkill?.command, '/ExampleSkills:claude-plugin');
|
|
assert.equal(pluginSkill?.description, 'Claude plugin skill');
|
|
assert.match(
|
|
pluginSkill?.sourcePath ?? '',
|
|
/cache[\\/]anthropic-agent-skills[\\/]example-skills[\\/]def456[\\/]skills[\\/]/,
|
|
);
|
|
|
|
const secondPluginSkill = byName.get('claude-plugin-second');
|
|
assert.equal(secondPluginSkill?.scope, 'plugin');
|
|
assert.equal(secondPluginSkill?.command, '/ExampleSkills:claude-plugin-second');
|
|
|
|
const nestedPluginSkill = byName.get('claude-plugin-nested');
|
|
assert.equal(nestedPluginSkill?.scope, 'plugin');
|
|
assert.equal(nestedPluginSkill?.command, '/ExampleSkills:claude-plugin-nested');
|
|
assert.equal(nestedPluginSkill?.description, 'Nested Claude plugin skill');
|
|
|
|
const siblingPluginSkill = byName.get('claude-plugin-sibling');
|
|
assert.equal(siblingPluginSkill?.scope, 'plugin');
|
|
assert.equal(siblingPluginSkill?.pluginName, 'example-skills');
|
|
assert.equal(siblingPluginSkill?.command, '/example-skills:claude-plugin-sibling');
|
|
assert.equal(siblingPluginSkill?.description, 'Sibling Claude plugin skill');
|
|
assert.equal(byName.has('disabled-command'), 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 {
|
|
restoreHomeDir();
|
|
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* This test covers Codex repository/user/system skill folders and verifies that
|
|
* repository lookup includes cwd, parent, and git root skill locations.
|
|
*/
|
|
test('providerSkillsService lists codex repository, user, and system skills', { concurrency: false }, async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-codex-'));
|
|
const repoRoot = path.join(tempRoot, 'repo');
|
|
const workspacePath = path.join(repoRoot, 'packages', 'app');
|
|
await fs.mkdir(path.join(repoRoot, '.git'), { recursive: true });
|
|
await fs.mkdir(workspacePath, { recursive: true });
|
|
|
|
const restoreHomeDir = patchHomeDir(tempRoot);
|
|
try {
|
|
await writeSkill(
|
|
path.join(workspacePath, '.agents', 'skills'),
|
|
'codex-cwd-dir',
|
|
'codex-cwd',
|
|
'Codex cwd skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(repoRoot, 'packages', '.agents', 'skills'),
|
|
'codex-parent-dir',
|
|
'codex-parent',
|
|
'Codex parent skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(repoRoot, '.agents', 'skills'),
|
|
'codex-root-dir',
|
|
'codex-root',
|
|
'Codex root skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(tempRoot, '.agents', 'skills'),
|
|
'codex-user-dir',
|
|
'codex-user',
|
|
'Codex user skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(tempRoot, '.codex', 'skills', '.system'),
|
|
'codex-system-dir',
|
|
'codex-system',
|
|
'Codex system skill',
|
|
);
|
|
|
|
const skills = await providerSkillsService.listProviderSkills('codex', { workspacePath });
|
|
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
|
|
|
assert.equal(byName.get('codex-cwd')?.scope, 'repo');
|
|
assert.equal(byName.get('codex-parent')?.scope, 'repo');
|
|
assert.equal(byName.get('codex-root')?.scope, 'repo');
|
|
assert.equal(byName.get('codex-user')?.scope, 'user');
|
|
assert.equal(byName.get('codex-system')?.scope, 'system');
|
|
assert.equal(byName.get('codex-root')?.command, '$codex-root');
|
|
} finally {
|
|
restoreHomeDir();
|
|
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* This test covers Gemini and Cursor skill directory rules, including shared
|
|
* `.agents/skills` project support.
|
|
*/
|
|
test('providerSkillsService lists gemini and cursor skills from their configured directories', { concurrency: false }, async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-gc-'));
|
|
const workspacePath = path.join(tempRoot, 'workspace');
|
|
await fs.mkdir(workspacePath, { recursive: true });
|
|
|
|
const restoreHomeDir = patchHomeDir(tempRoot);
|
|
try {
|
|
await writeSkill(
|
|
path.join(tempRoot, '.gemini', 'skills'),
|
|
'gemini-user-dir',
|
|
'gemini-user',
|
|
'Gemini user skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(tempRoot, '.agents', 'skills'),
|
|
'agents-user-dir',
|
|
'agents-user',
|
|
'Agents user skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(workspacePath, '.gemini', 'skills'),
|
|
'gemini-project-dir',
|
|
'gemini-project',
|
|
'Gemini project skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(workspacePath, '.agents', 'skills'),
|
|
'agents-project-dir',
|
|
'agents-project',
|
|
'Agents project skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(workspacePath, '.cursor', 'skills'),
|
|
'cursor-project-dir',
|
|
'cursor-project',
|
|
'Cursor project skill',
|
|
);
|
|
await writeSkill(
|
|
path.join(tempRoot, '.cursor', 'skills'),
|
|
'cursor-user-dir',
|
|
'cursor-user',
|
|
'Cursor user skill',
|
|
);
|
|
|
|
const geminiSkills = await providerSkillsService.listProviderSkills('gemini', { workspacePath });
|
|
const geminiByName = new Map(geminiSkills.map((skill) => [skill.name, skill]));
|
|
assert.equal(geminiByName.get('gemini-user')?.scope, 'user');
|
|
assert.equal(geminiByName.get('agents-user')?.scope, 'user');
|
|
assert.equal(geminiByName.get('gemini-project')?.scope, 'project');
|
|
assert.equal(geminiByName.get('agents-project')?.scope, 'project');
|
|
assert.equal(geminiByName.get('gemini-project')?.command, '/gemini-project');
|
|
|
|
const cursorSkills = await providerSkillsService.listProviderSkills('cursor', { workspacePath });
|
|
const cursorByName = new Map(cursorSkills.map((skill) => [skill.name, skill]));
|
|
assert.equal(cursorByName.get('agents-project')?.scope, 'project');
|
|
assert.equal(cursorByName.get('cursor-project')?.scope, 'project');
|
|
assert.equal(cursorByName.get('cursor-user')?.scope, 'user');
|
|
assert.equal(cursorByName.get('cursor-user')?.command, '/cursor-user');
|
|
} finally {
|
|
restoreHomeDir();
|
|
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|