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:
Haileyesus
2026-06-21 01:17:23 +03:00
parent 4712431be8
commit be9fdd165e
22 changed files with 1578 additions and 14 deletions

View File

@@ -99,6 +99,14 @@ export class ClaudeSkillsProvider extends SkillsProvider {
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(getClaudeHomePath(), 'skills'),
commandPrefix: '/',
};
}
private async listPluginSkills(claudeHomePath: string): Promise<ProviderSkill[]> {
const settings = await readJsonConfig(path.join(claudeHomePath, 'settings.json'));
const enabledPlugins = readObjectRecord(settings.enabledPlugins);

View File

@@ -57,4 +57,12 @@ export class CodexSkillsProvider extends SkillsProvider {
return sources;
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.agents', 'skills'),
commandPrefix: '$',
};
}
}

View File

@@ -28,4 +28,12 @@ export class CursorSkillsProvider extends SkillsProvider {
},
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.cursor', 'skills'),
commandPrefix: '/',
};
}
}

View File

@@ -33,4 +33,12 @@ export class GeminiSkillsProvider extends SkillsProvider {
},
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
commandPrefix: '/',
};
}
}

View File

@@ -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',

View File

@@ -1,5 +1,9 @@
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { ProviderSkill, ProviderSkillListOptions } from '@/shared/types.js';
import type {
ProviderSkill,
ProviderSkillCreateInput,
ProviderSkillListOptions,
} from '@/shared/types.js';
export const providerSkillsService = {
/**
@@ -12,4 +16,15 @@ export const providerSkillsService = {
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.listSkills(options);
},
/**
* Writes one or more global skills for one provider.
*/
async addProviderSkills(
providerName: string,
input: ProviderSkillCreateInput,
): Promise<ProviderSkill[]> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.addSkills(input);
},
};

View File

@@ -1,20 +1,75 @@
import path from 'node:path';
import { mkdir, writeFile } from 'node:fs/promises';
import type { IProviderSkills } from '@/shared/interfaces.js';
import type {
LLMProvider,
ProviderSkillCreateInput,
ProviderSkill,
ProviderSkillListOptions,
ProviderSkillSource,
} from '@/shared/types.js';
import {
findProviderSkillMarkdownFiles,
readOptionalString,
readProviderSkillMarkdownDefinitionFromContent,
readProviderSkillMarkdownDefinition,
AppError,
} from '@/shared/utils.js';
const resolveWorkspacePath = (workspacePath?: string): string =>
path.resolve(workspacePath ?? process.cwd());
const stripMarkdownExtension = (value: string): string => value.replace(/\.md$/i, '');
const normalizeSkillDirectoryName = (value: string): string => (
value
.trim()
.replace(/[\\/]+/g, '-')
.replace(/[<>:"|?*\x00-\x1F]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^\.+|\.+$/g, '')
.replace(/^-+|-+$/g, '')
);
const resolveSkillSupportingFilePath = (
skillDirectoryPath: string,
relativePath: string,
entryIndex: number,
): string => {
const normalizedRelativePath = relativePath.trim().replace(/\\/g, '/');
const pathSegments = normalizedRelativePath.split('/');
if (
!normalizedRelativePath
|| path.isAbsolute(normalizedRelativePath)
|| pathSegments.some((segment) => !segment || segment === '.' || segment === '..')
|| normalizedRelativePath.toLowerCase() === 'skill.md'
) {
throw new AppError(
`Skill entry ${entryIndex + 1} includes an invalid supporting file path "${relativePath}".`,
{
code: 'PROVIDER_SKILL_FILE_PATH_INVALID',
statusCode: 400,
},
);
}
const resolvedSkillDirectoryPath = path.resolve(skillDirectoryPath);
const resolvedFilePath = path.resolve(resolvedSkillDirectoryPath, ...pathSegments);
if (!resolvedFilePath.startsWith(`${resolvedSkillDirectoryPath}${path.sep}`)) {
throw new AppError(
`Skill entry ${entryIndex + 1} supporting files must stay inside the skill directory.`,
{
code: 'PROVIDER_SKILL_FILE_PATH_INVALID',
statusCode: 400,
},
);
}
return resolvedFilePath;
};
/**
* Shared skills provider for provider-specific skill source discovery.
*/
@@ -60,5 +115,109 @@ export abstract class SkillsProvider implements IProviderSkills {
return skills;
}
async addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]> {
const globalSkillSource = await this.getGlobalSkillSource();
if (!globalSkillSource) {
throw new AppError(`${this.provider} does not support managed global skills.`, {
code: 'PROVIDER_SKILLS_WRITE_UNSUPPORTED',
statusCode: 400,
});
}
if (!Array.isArray(input.entries) || input.entries.length === 0) {
throw new AppError('At least one skill entry is required.', {
code: 'PROVIDER_SKILLS_REQUIRED',
statusCode: 400,
});
}
await mkdir(globalSkillSource.rootDir, { recursive: true });
const createdSkills: ProviderSkill[] = [];
const seenSkillPaths = new Set<string>();
for (const [index, entry] of input.entries.entries()) {
const content = typeof entry.content === 'string' ? entry.content.trim() : '';
if (!content) {
throw new AppError(`Skill entry ${index + 1} must include markdown content.`, {
code: 'PROVIDER_SKILL_CONTENT_REQUIRED',
statusCode: 400,
});
}
const fileNameFallback = readOptionalString(entry.fileName);
const requestedDirectoryName = readOptionalString(entry.directoryName);
const fallbackSkillName = requestedDirectoryName
?? (fileNameFallback ? stripMarkdownExtension(fileNameFallback) : `skill-${index + 1}`);
const definition = readProviderSkillMarkdownDefinitionFromContent(content, fallbackSkillName);
const resolvedDirectoryName = normalizeSkillDirectoryName(
requestedDirectoryName ?? definition.name,
);
if (!resolvedDirectoryName) {
throw new AppError(`Skill entry ${index + 1} must include a valid skill name.`, {
code: 'PROVIDER_SKILL_NAME_REQUIRED',
statusCode: 400,
});
}
const skillDirectoryPath = path.join(globalSkillSource.rootDir, resolvedDirectoryName);
const skillPath = path.join(skillDirectoryPath, 'SKILL.md');
const normalizedSkillPath = path.resolve(skillPath);
if (seenSkillPaths.has(normalizedSkillPath)) {
throw new AppError(`Duplicate skill target "${resolvedDirectoryName}" in one request.`, {
code: 'PROVIDER_SKILL_DUPLICATE_TARGET',
statusCode: 400,
});
}
seenSkillPaths.add(normalizedSkillPath);
const supportingFiles = (entry.files ?? []).map((file) => ({
targetPath: resolveSkillSupportingFilePath(skillDirectoryPath, file.relativePath, index),
content: file.encoding === 'base64'
? Buffer.from(file.content, 'base64')
: file.content,
}));
const seenSupportingPaths = new Set<string>();
for (const file of supportingFiles) {
if (seenSupportingPaths.has(file.targetPath)) {
throw new AppError(`Skill entry ${index + 1} includes a duplicate supporting file path.`, {
code: 'PROVIDER_SKILL_DUPLICATE_FILE',
statusCode: 400,
});
}
seenSupportingPaths.add(file.targetPath);
}
await mkdir(skillDirectoryPath, { recursive: true });
await writeFile(skillPath, `${content}\n`, 'utf8');
for (const file of supportingFiles) {
await mkdir(path.dirname(file.targetPath), { recursive: true });
await writeFile(file.targetPath, file.content);
}
const command = globalSkillSource.commandForSkill
? globalSkillSource.commandForSkill(definition.name)
: `${globalSkillSource.commandPrefix ?? '/'}${definition.name}`;
createdSkills.push({
provider: this.provider,
name: definition.name,
description: definition.description,
command,
scope: globalSkillSource.scope,
sourcePath: skillPath,
pluginName: globalSkillSource.pluginName,
pluginId: globalSkillSource.pluginId,
});
}
return createdSkills;
}
protected abstract getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]>;
protected async getGlobalSkillSource(): Promise<ProviderSkillSource | null> {
return null;
}
}

View File

@@ -510,3 +510,144 @@ test('providerSkillsService lists gemini and cursor skills from their configured
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers managed global skill creation for providers that own a
* writable user skill directory.
*/
test('providerSkillsService adds global skills for claude, codex, gemini, and cursor', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-create-'));
const restoreHomeDir = patchHomeDir(tempRoot);
try {
const createdClaudeSkills = await providerSkillsService.addProviderSkills('claude', {
entries: [
{
directoryName: 'claude-global-dir',
content: '---\nname: claude-global\ndescription: Claude global skill\n---\n\nClaude body.\n',
},
],
});
const createdClaudeSkill = createdClaudeSkills[0];
assert.ok(createdClaudeSkill);
assert.equal(createdClaudeSkill.command, '/claude-global');
assert.equal(
createdClaudeSkill.sourcePath.endsWith(path.join('.claude', 'skills', 'claude-global-dir', 'SKILL.md')),
true,
);
assert.match(
await fs.readFile(createdClaudeSkill.sourcePath, 'utf8'),
/Claude body\./,
);
const createdCodexSkills = await providerSkillsService.addProviderSkills('codex', {
entries: [
{
fileName: 'SKILL.md',
content: '---\nname: codex-global\ndescription: Codex global skill\n---\n\nCodex body.\n',
files: [
{
relativePath: 'scripts/run.js',
content: Buffer.from('console.log("codex skill");\n').toString('base64'),
encoding: 'base64',
},
],
},
],
});
const createdCodexSkill = createdCodexSkills[0];
assert.ok(createdCodexSkill);
assert.equal(createdCodexSkill.command, '$codex-global');
assert.equal(
createdCodexSkill.sourcePath.endsWith(path.join('.agents', 'skills', 'codex-global', 'SKILL.md')),
true,
);
assert.equal(
await fs.readFile(path.join(path.dirname(createdCodexSkill.sourcePath), 'scripts', 'run.js'), 'utf8'),
'console.log("codex skill");\n',
);
const createdGeminiSkills = await providerSkillsService.addProviderSkills('gemini', {
entries: [
{
directoryName: 'gemini-global-dir',
content: '---\nname: gemini-global\ndescription: Gemini global skill\n---\n\nGemini body.\n',
},
],
});
const createdGeminiSkill = createdGeminiSkills[0];
assert.ok(createdGeminiSkill);
assert.equal(createdGeminiSkill.command, '/gemini-global');
assert.equal(
createdGeminiSkill.sourcePath.endsWith(path.join('.gemini', 'skills', 'gemini-global-dir', 'SKILL.md')),
true,
);
const createdCursorSkills = await providerSkillsService.addProviderSkills('cursor', {
entries: [
{
directoryName: 'cursor-global-dir',
content: '---\nname: cursor-global\ndescription: Cursor global skill\n---\n\nCursor body.\n',
},
],
});
const createdCursorSkill = createdCursorSkills[0];
assert.ok(createdCursorSkill);
assert.equal(createdCursorSkill.command, '/cursor-global');
assert.equal(
createdCursorSkill.sourcePath.endsWith(path.join('.cursor', 'skills', 'cursor-global-dir', 'SKILL.md')),
true,
);
const listedClaudeSkills = await providerSkillsService.listProviderSkills('claude');
assert.equal(listedClaudeSkills.some((skill) => skill.name === 'claude-global'), true);
const listedCodexSkills = await providerSkillsService.listProviderSkills('codex');
assert.equal(listedCodexSkills.some((skill) => skill.name === 'codex-global'), true);
const listedGeminiSkills = await providerSkillsService.listProviderSkills('gemini');
assert.equal(listedGeminiSkills.some((skill) => skill.name === 'gemini-global'), true);
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);
await assert.rejects(
providerSkillsService.addProviderSkills('codex', {
entries: [
{
content: '---\nname: unsafe-skill\n---\n',
files: [
{
relativePath: '../outside.js',
content: '',
encoding: 'utf8',
},
],
},
],
}),
/invalid supporting file path/i,
);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* OpenCode reuses other providers' skill folders, so it should not accept
* direct skill writes through the managed provider endpoint.
*/
test('providerSkillsService rejects managed skill creation for opencode', { concurrency: false }, async () => {
await assert.rejects(
providerSkillsService.addProviderSkills('opencode', {
entries: [
{
directoryName: 'opencode-global-dir',
content: '---\nname: opencode-global\ndescription: Unsupported skill\n---\n\nOpenCode body.\n',
},
],
}),
/does not support managed global skills/i,
);
});