mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-27 14:15:26 +08:00
feat(skills): add provider skill management
Users need one settings surface to discover and install skills without manually navigating provider-specific directories. Add provider-backed global skill installation for Claude, Codex, Gemini, and Cursor, while keeping OpenCode read-only because it reuses other providers' skill locations. Add a responsive Skills settings tab with scoped discovery, search, refresh controls, markdown and folder uploads, upload feedback, and overflow-safe layouts. Validate bundled skill files and paths before writing them, preserve scripts and assets, and cover provider discovery and installation behavior with tests.
This commit is contained in:
@@ -12,6 +12,8 @@ import type {
|
||||
McpScope,
|
||||
McpTransport,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderSkillCreateFile,
|
||||
ProviderSkillCreateInput,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||
@@ -179,6 +181,103 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
|
||||
};
|
||||
};
|
||||
|
||||
const parseProviderSkillCreatePayload = (payload: unknown): ProviderSkillCreateInput => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const body = payload as Record<string, unknown>;
|
||||
const rawEntries = Array.isArray(body.entries)
|
||||
? body.entries
|
||||
: typeof body.content === 'string'
|
||||
? [{
|
||||
content: body.content,
|
||||
directoryName: body.directoryName,
|
||||
fileName: body.fileName,
|
||||
}]
|
||||
: null;
|
||||
|
||||
if (!rawEntries || rawEntries.length === 0) {
|
||||
throw new AppError('At least one skill entry is required.', {
|
||||
code: 'PROVIDER_SKILLS_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const entries = rawEntries.map((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new AppError(`Skill entry ${index + 1} must be an object.`, {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const record = entry as Record<string, unknown>;
|
||||
const content = typeof record.content === 'string' ? record.content : '';
|
||||
const directoryName = readOptionalQueryString(record.directoryName);
|
||||
const fileName = readOptionalQueryString(record.fileName);
|
||||
const rawFiles = record.files;
|
||||
|
||||
if (!content.trim()) {
|
||||
throw new AppError(`Skill entry ${index + 1} must include markdown content.`, {
|
||||
code: 'PROVIDER_SKILL_CONTENT_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (rawFiles !== undefined && !Array.isArray(rawFiles)) {
|
||||
throw new AppError(`Skill entry ${index + 1} files must be an array.`, {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const files: ProviderSkillCreateFile[] | undefined = rawFiles?.map((file, fileIndex) => {
|
||||
if (!file || typeof file !== 'object') {
|
||||
throw new AppError(`Skill entry ${index + 1} file ${fileIndex + 1} must be an object.`, {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const fileRecord = file as Record<string, unknown>;
|
||||
const relativePath = readOptionalQueryString(fileRecord.relativePath);
|
||||
const fileContent = typeof fileRecord.content === 'string' ? fileRecord.content : null;
|
||||
const encoding = fileRecord.encoding === 'utf8' || fileRecord.encoding === 'base64'
|
||||
? fileRecord.encoding
|
||||
: null;
|
||||
|
||||
if (!relativePath || fileContent === null || !encoding) {
|
||||
throw new AppError(
|
||||
`Skill entry ${index + 1} file ${fileIndex + 1} requires relativePath, content, and encoding.`,
|
||||
{
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
relativePath,
|
||||
content: fileContent,
|
||||
encoding,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
content,
|
||||
directoryName,
|
||||
fileName,
|
||||
files,
|
||||
};
|
||||
});
|
||||
|
||||
return { entries };
|
||||
};
|
||||
|
||||
const parseProvider = (value: unknown): LLMProvider => {
|
||||
const normalized = normalizeProviderParam(value);
|
||||
if (
|
||||
@@ -320,6 +419,16 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/skills',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const input = parseProviderSkillCreatePayload(req.body);
|
||||
const skills = await providerSkillsService.addProviderSkills(provider, input);
|
||||
res.json(createApiSuccessResponse({ provider, skills }));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- MCP routes -----------------
|
||||
router.get(
|
||||
'/:provider/mcp/servers',
|
||||
|
||||
Reference in New Issue
Block a user