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.
This commit is contained in:
@@ -4,6 +4,8 @@ import type {
|
||||
LLMProvider,
|
||||
McpScope,
|
||||
NormalizedMessage,
|
||||
ProviderSkill,
|
||||
ProviderSkillListOptions,
|
||||
ProviderAuthStatus,
|
||||
ProviderMcpServer,
|
||||
UpsertProviderMcpServerInput,
|
||||
@@ -20,6 +22,7 @@ export interface IProvider {
|
||||
readonly id: LLMProvider;
|
||||
readonly mcp: IProviderMcp;
|
||||
readonly auth: IProviderAuth;
|
||||
readonly skills: IProviderSkills;
|
||||
readonly sessions: IProviderSessions;
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer;
|
||||
}
|
||||
@@ -39,6 +42,22 @@ export interface IProviderAuth {
|
||||
getStatus(): Promise<ProviderAuthStatus>;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER SKILLS INTERFACE ------------
|
||||
/**
|
||||
* Skills contract for one provider.
|
||||
*
|
||||
* Implementations discover provider-native skill markdown locations and return
|
||||
* normalized skill records with the exact command syntax expected by that
|
||||
* provider. Each skill is read from a `SKILL.md` file under its skill directory.
|
||||
*/
|
||||
export interface IProviderSkills {
|
||||
/**
|
||||
* Lists all skills visible to this provider for the optional workspace.
|
||||
*/
|
||||
listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]>;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER MCP INTERFACE ------------
|
||||
/**
|
||||
|
||||
@@ -171,6 +171,69 @@ export type FetchHistoryResult = {
|
||||
tokenUsage?: unknown;
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER SKILL TYPES ------------
|
||||
/**
|
||||
* Scope where a provider skill definition was discovered.
|
||||
*
|
||||
* Provider skill adapters should use this to describe the origin of each
|
||||
* skill markdown file without leaking provider-specific folder names into route
|
||||
* contracts. `repo` is used for Codex repository lookup locations, while
|
||||
* `project` is used for providers that treat workspace-local skills as project
|
||||
* scoped.
|
||||
*/
|
||||
export type ProviderSkillScope = 'user' | 'project' | 'plugin' | 'repo' | 'admin' | 'system';
|
||||
|
||||
/**
|
||||
* Shared input accepted by provider skill listing operations.
|
||||
*
|
||||
* Routes pass `workspacePath` when a caller wants project/repository skills for
|
||||
* a specific folder. Providers should fall back to the backend process cwd when
|
||||
* this option is omitted.
|
||||
*/
|
||||
export type ProviderSkillListOptions = {
|
||||
workspacePath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalized skill record returned by provider skill adapters.
|
||||
*
|
||||
* The `command` value is the exact invocation text the selected provider expects
|
||||
* for this skill. Claude plugin skills use a namespaced command such as
|
||||
* `/plugin-name:skill-name`, while Codex skills use the `$skill-name` form.
|
||||
* `sourcePath` points to the skill markdown file that produced the record so
|
||||
* callers can distinguish duplicate skill names across scopes.
|
||||
*/
|
||||
export type ProviderSkill = {
|
||||
provider: LLMProvider;
|
||||
name: string;
|
||||
description: string;
|
||||
command: string;
|
||||
scope: ProviderSkillScope;
|
||||
sourcePath: string;
|
||||
pluginName?: string;
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal source descriptor consumed by shared provider skill discovery logic.
|
||||
*
|
||||
* Concrete provider adapters build these records from their native lookup rules.
|
||||
* The shared skills provider then scans `rootDir` for child skill markdown files
|
||||
* and uses `commandForSkill` or `commandPrefix` to produce the provider-specific
|
||||
* invocation command. Set `recursive` only when a provider stores skills under
|
||||
* arbitrary nested folders below the source root.
|
||||
*/
|
||||
export type ProviderSkillSource = {
|
||||
scope: ProviderSkillScope;
|
||||
rootDir: string;
|
||||
recursive?: boolean;
|
||||
commandPrefix?: '/' | '$';
|
||||
commandForSkill?: (skillName: string) => string;
|
||||
pluginName?: string;
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
//----------------- SHARED ERROR TYPES ------------
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import matter from 'gray-matter';
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
import type {
|
||||
@@ -503,6 +504,99 @@ export const writeJsonConfig = async (filePath: string, data: Record<string, unk
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER SKILL FILE UTILITIES ------------
|
||||
/**
|
||||
* Finds direct child skill markdown files under a provider skill root.
|
||||
*
|
||||
* Skill systems usually store one skill per child directory, so direct mode
|
||||
* scans only `<root>/<skill-name>/SKILL.md`. Recursive mode is reserved for
|
||||
* provider sources that can nest skills arbitrarily, and it returns every
|
||||
* descendant `SKILL.md`. Missing or unreadable roots return an empty list
|
||||
* because users may not have every provider installed or configured.
|
||||
*/
|
||||
export async function findProviderSkillMarkdownFiles(
|
||||
rootDir: string,
|
||||
options: { recursive?: boolean } = {},
|
||||
): Promise<string[]> {
|
||||
const skillFiles: string[] = [];
|
||||
|
||||
const collectRecursive = async (dirPath: string): Promise<void> => {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const skillPath = path.join(dirPath, 'SKILL.md');
|
||||
const skillStats = await stat(skillPath);
|
||||
if (skillStats.isFile()) {
|
||||
skillFiles.push(skillPath);
|
||||
}
|
||||
} catch {
|
||||
// Directories without SKILL.md are expected while walking plugin trees.
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
await collectRecursive(path.join(dirPath, entry.name));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (options.recursive) {
|
||||
await collectRecursive(rootDir);
|
||||
return skillFiles.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(rootDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillPath = path.join(rootDir, entry.name, 'SKILL.md');
|
||||
try {
|
||||
const skillStats = await stat(skillPath);
|
||||
if (skillStats.isFile()) {
|
||||
skillFiles.push(skillPath);
|
||||
}
|
||||
} catch {
|
||||
// A partial skill directory should not block discovery of sibling skills.
|
||||
}
|
||||
}
|
||||
|
||||
return skillFiles.sort((left, right) => left.localeCompare(right));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the `name` and `description` fields from a provider skill markdown file.
|
||||
*
|
||||
* The metadata is expected in markdown front matter. If a skill omits `name`, the
|
||||
* parent directory name is used as a stable fallback so providers can still
|
||||
* expose the skill. Missing descriptions are normalized to an empty string.
|
||||
*/
|
||||
export async function readProviderSkillMarkdownDefinition(
|
||||
skillPath: string,
|
||||
): Promise<{ name: string; description: string }> {
|
||||
const content = await readFile(skillPath, 'utf8');
|
||||
const parsed = matter(content);
|
||||
const data = readObjectRecord(parsed.data) ?? {};
|
||||
const fallbackName = path.basename(path.dirname(skillPath));
|
||||
|
||||
return {
|
||||
name: readOptionalString(data.name) ?? fallbackName,
|
||||
description: readOptionalString(data.description) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- SESSION SYNCHRONIZER TITLE HELPERS ------------
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user