mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 23:15:33 +08: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
258 lines
7.8 KiB
TypeScript
258 lines
7.8 KiB
TypeScript
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
|
import { parseFrontMatter } from '@/shared/frontmatter.js';
|
|
import type {
|
|
ProviderSkill,
|
|
ProviderSkillListOptions,
|
|
ProviderSkillSource,
|
|
} from '@/shared/types.js';
|
|
import {
|
|
findProviderSkillMarkdownFiles,
|
|
readJsonConfig,
|
|
readObjectRecord,
|
|
readOptionalString,
|
|
readProviderSkillMarkdownDefinition,
|
|
} from '@/shared/utils.js';
|
|
|
|
const getClaudeHomePath = (): string => path.join(os.homedir(), '.claude');
|
|
|
|
const getClaudePluginName = (pluginId: string): string | null => {
|
|
const normalizedPluginId = pluginId.trim();
|
|
if (!normalizedPluginId || normalizedPluginId === '@') {
|
|
return null;
|
|
}
|
|
|
|
const [pluginName] = normalizedPluginId.split('@');
|
|
return readOptionalString(pluginName) ?? null;
|
|
};
|
|
|
|
const stripMarkdownExtension = (filename: string): string =>
|
|
filename.replace(/\.md$/i, '');
|
|
|
|
const pathExistsAsDirectory = async (directoryPath: string): Promise<boolean> => {
|
|
try {
|
|
const directoryStats = await stat(directoryPath);
|
|
return directoryStats.isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const listChildDirectories = async (directoryPath: string): Promise<string[]> => {
|
|
try {
|
|
const entries = await readdir(directoryPath, { withFileTypes: true });
|
|
return entries
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => path.join(directoryPath, entry.name))
|
|
.sort((left, right) => left.localeCompare(right));
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const readClaudePluginName = async (
|
|
installPath: string,
|
|
pluginId: string,
|
|
): Promise<string | null> => {
|
|
try {
|
|
const pluginConfig = await readJsonConfig(
|
|
path.join(installPath, '.claude-plugin', 'plugin.json'),
|
|
);
|
|
|
|
// Older or partial plugin installs may not have plugin.json yet. Falling
|
|
// back keeps discovery useful without inventing a separate namespace.
|
|
return readOptionalString(pluginConfig.name) ?? getClaudePluginName(pluginId);
|
|
} catch {
|
|
return getClaudePluginName(pluginId);
|
|
}
|
|
};
|
|
|
|
export class ClaudeSkillsProvider extends SkillsProvider {
|
|
constructor() {
|
|
super('claude');
|
|
}
|
|
|
|
async listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]> {
|
|
return [
|
|
...(await super.listSkills(options)),
|
|
...(await this.listPluginSkills(getClaudeHomePath())),
|
|
];
|
|
}
|
|
|
|
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
|
const claudeHomePath = getClaudeHomePath();
|
|
|
|
return [
|
|
{
|
|
scope: 'user',
|
|
rootDir: path.join(claudeHomePath, 'skills'),
|
|
commandPrefix: '/',
|
|
},
|
|
{
|
|
scope: 'project',
|
|
rootDir: path.join(workspacePath, '.claude', 'skills'),
|
|
commandPrefix: '/',
|
|
},
|
|
];
|
|
}
|
|
|
|
private async listPluginSkills(claudeHomePath: string): Promise<ProviderSkill[]> {
|
|
const settings = await readJsonConfig(path.join(claudeHomePath, 'settings.json'));
|
|
const enabledPlugins = readObjectRecord(settings.enabledPlugins);
|
|
if (!enabledPlugins) {
|
|
return [];
|
|
}
|
|
|
|
const installedConfig = await readJsonConfig(
|
|
path.join(claudeHomePath, 'plugins', 'installed_plugins.json'),
|
|
);
|
|
const installedPlugins = readObjectRecord(installedConfig.plugins);
|
|
if (!installedPlugins) {
|
|
return [];
|
|
}
|
|
|
|
const skills: ProviderSkill[] = [];
|
|
const visitedPluginFolders = new Set<string>();
|
|
const pluginEntries = Object.entries(enabledPlugins)
|
|
.sort(([left], [right]) => left.localeCompare(right));
|
|
for (const [pluginId, enabled] of pluginEntries) {
|
|
if (enabled !== true) {
|
|
continue;
|
|
}
|
|
|
|
const installs = installedPlugins[pluginId];
|
|
if (!Array.isArray(installs)) {
|
|
continue;
|
|
}
|
|
|
|
for (const install of installs) {
|
|
const installRecord = readObjectRecord(install);
|
|
const installPath = readOptionalString(installRecord?.installPath);
|
|
if (!installPath) {
|
|
continue;
|
|
}
|
|
|
|
// Claude's installed path points at one version folder; the usable
|
|
// plugin payloads live in the direct child folders beside it.
|
|
const pluginFolders = await listChildDirectories(path.dirname(installPath));
|
|
for (const pluginFolder of pluginFolders) {
|
|
const pluginFolderKey = `${pluginId}:${path.resolve(pluginFolder)}`;
|
|
if (visitedPluginFolders.has(pluginFolderKey)) {
|
|
continue;
|
|
}
|
|
visitedPluginFolders.add(pluginFolderKey);
|
|
|
|
const pluginName = await readClaudePluginName(pluginFolder, pluginId);
|
|
if (!pluginName) {
|
|
continue;
|
|
}
|
|
|
|
const commandsPath = path.join(pluginFolder, 'commands');
|
|
if (await pathExistsAsDirectory(commandsPath)) {
|
|
skills.push(
|
|
...(await this.listPluginCommandSkills(commandsPath, pluginId, pluginName)),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const skillsPath = path.join(pluginFolder, 'skills');
|
|
if (!(await pathExistsAsDirectory(skillsPath))) {
|
|
continue;
|
|
}
|
|
|
|
skills.push(
|
|
...(await this.listPluginSkillMarkdowns(pluginFolder, pluginId, pluginName)),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return skills;
|
|
}
|
|
|
|
private async listPluginCommandSkills(
|
|
commandsPath: string,
|
|
pluginId: string,
|
|
pluginName: string,
|
|
): Promise<ProviderSkill[]> {
|
|
const skills: ProviderSkill[] = [];
|
|
|
|
try {
|
|
const entries = await readdir(commandsPath, { withFileTypes: true });
|
|
const commandFiles = entries
|
|
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.md'))
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
|
|
for (const commandFile of commandFiles) {
|
|
const sourcePath = path.join(commandsPath, commandFile.name);
|
|
try {
|
|
const definition = await this.readPluginCommandDefinition(sourcePath);
|
|
skills.push({
|
|
provider: this.provider,
|
|
name: definition.name,
|
|
description: definition.description,
|
|
command: `/${pluginName}:${definition.name}`,
|
|
scope: 'plugin',
|
|
sourcePath,
|
|
pluginName,
|
|
pluginId,
|
|
});
|
|
} catch {
|
|
// Malformed command markdown should not block sibling plugin commands.
|
|
}
|
|
}
|
|
} catch {
|
|
// Missing or unreadable command folders are treated as empty plugin command sets.
|
|
}
|
|
|
|
return skills;
|
|
}
|
|
|
|
private async readPluginCommandDefinition(
|
|
commandPath: string,
|
|
): Promise<{ name: string; description: string }> {
|
|
const content = await readFile(commandPath, 'utf8');
|
|
const parsed = parseFrontMatter(content);
|
|
const data = readObjectRecord(parsed.data) ?? {};
|
|
|
|
return {
|
|
name: stripMarkdownExtension(path.basename(commandPath)),
|
|
description: readOptionalString(data.description) ?? '',
|
|
};
|
|
}
|
|
|
|
private async listPluginSkillMarkdowns(
|
|
installPath: string,
|
|
pluginId: string,
|
|
pluginName: string,
|
|
): Promise<ProviderSkill[]> {
|
|
const skillFiles = await findProviderSkillMarkdownFiles(path.join(installPath, 'skills'), {
|
|
recursive: true,
|
|
});
|
|
const skills: ProviderSkill[] = [];
|
|
|
|
for (const skillPath of skillFiles) {
|
|
try {
|
|
const definition = await readProviderSkillMarkdownDefinition(skillPath);
|
|
skills.push({
|
|
provider: this.provider,
|
|
name: definition.name,
|
|
description: definition.description,
|
|
command: `/${pluginName}:${definition.name}`,
|
|
scope: 'plugin',
|
|
sourcePath: skillPath,
|
|
pluginName,
|
|
pluginId,
|
|
});
|
|
} catch {
|
|
// A bad plugin skill file should not block other installed plugin skills.
|
|
}
|
|
}
|
|
|
|
return skills;
|
|
}
|
|
}
|