From c5fe127958d830eee19d008d8634c0e7d77fe1b9 Mon Sep 17 00:00:00 2001 From: Haile <118998054+blackmammoth@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:45:27 +0300 Subject: [PATCH] feat(skills): add provider skill management (#909) * 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. * fix(skills): preserve uploaded skill folders Folder drops discarded supporting scripts and assets. Keep relative paths and upload every file from the selected skill folder. Use the selected folder name for installation and cover it in provider tests. * fix(skills): restrict standalone skill uploads Only show Markdown files when selecting standalone skills. Normalize browser file paths so SKILL.md is not mistaken for a folder named dot. * fix(skills): validate installs before writing Preserve bundled files and normalize fallback names across skill installation paths. Validate complete batches before writing and reject existing targets to avoid partial installs. Keep project metadata and make folder selection tolerant of casing and cancelled dialogs. * fix(skills): overwrite existing installations Replace an existing skill directory instead of rejecting a duplicate installation. Remove stale supporting files so the installed directory exactly matches the new upload. --- .../list/claude/claude-skills.provider.ts | 8 + .../list/codex/codex-skills.provider.ts | 8 + .../list/cursor/cursor-skills.provider.ts | 8 + .../list/gemini/gemini-skills.provider.ts | 8 + server/modules/providers/provider.routes.ts | 110 +++ .../providers/services/skills.service.ts | 17 +- .../shared/skills/skills.provider.ts | 180 +++++ server/modules/providers/tests/skills.test.ts | 192 +++++ server/shared/interfaces.ts | 10 + server/shared/types.ts | 41 ++ server/shared/utils.ts | 18 +- src/components/settings/types/types.ts | 2 +- src/components/settings/view/Settings.tsx | 7 +- .../agents-settings/AgentsSettingsTab.tsx | 19 +- .../sections/AgentCategoryContentSection.tsx | 16 +- .../sections/AgentCategoryTabsSection.tsx | 11 +- .../view/tabs/agents-settings/types.ts | 2 + .../skills/hooks/useProviderSkills.ts | 348 ++++++++++ src/components/skills/index.ts | 1 + src/components/skills/types.ts | 60 ++ src/components/skills/view/ProviderSkills.tsx | 654 ++++++++++++++++++ src/i18n/locales/en/settings.json | 1 + 22 files changed, 1707 insertions(+), 14 deletions(-) create mode 100644 src/components/skills/hooks/useProviderSkills.ts create mode 100644 src/components/skills/index.ts create mode 100644 src/components/skills/types.ts create mode 100644 src/components/skills/view/ProviderSkills.tsx diff --git a/server/modules/providers/list/claude/claude-skills.provider.ts b/server/modules/providers/list/claude/claude-skills.provider.ts index cbb1073a..5462b6b9 100644 --- a/server/modules/providers/list/claude/claude-skills.provider.ts +++ b/server/modules/providers/list/claude/claude-skills.provider.ts @@ -99,6 +99,14 @@ export class ClaudeSkillsProvider extends SkillsProvider { ]; } + protected async getGlobalSkillSource(): Promise { + return { + scope: 'user', + rootDir: path.join(getClaudeHomePath(), 'skills'), + commandPrefix: '/', + }; + } + private async listPluginSkills(claudeHomePath: string): Promise { const settings = await readJsonConfig(path.join(claudeHomePath, 'settings.json')); const enabledPlugins = readObjectRecord(settings.enabledPlugins); diff --git a/server/modules/providers/list/codex/codex-skills.provider.ts b/server/modules/providers/list/codex/codex-skills.provider.ts index fe61af51..b801ba2e 100644 --- a/server/modules/providers/list/codex/codex-skills.provider.ts +++ b/server/modules/providers/list/codex/codex-skills.provider.ts @@ -57,4 +57,12 @@ export class CodexSkillsProvider extends SkillsProvider { return sources; } + + protected async getGlobalSkillSource(): Promise { + return { + scope: 'user', + rootDir: path.join(os.homedir(), '.agents', 'skills'), + commandPrefix: '$', + }; + } } diff --git a/server/modules/providers/list/cursor/cursor-skills.provider.ts b/server/modules/providers/list/cursor/cursor-skills.provider.ts index 3da72b9f..a5d5e9c0 100644 --- a/server/modules/providers/list/cursor/cursor-skills.provider.ts +++ b/server/modules/providers/list/cursor/cursor-skills.provider.ts @@ -28,4 +28,12 @@ export class CursorSkillsProvider extends SkillsProvider { }, ]; } + + protected async getGlobalSkillSource(): Promise { + return { + scope: 'user', + rootDir: path.join(os.homedir(), '.cursor', 'skills'), + commandPrefix: '/', + }; + } } diff --git a/server/modules/providers/list/gemini/gemini-skills.provider.ts b/server/modules/providers/list/gemini/gemini-skills.provider.ts index e49746a5..f42ebb6f 100644 --- a/server/modules/providers/list/gemini/gemini-skills.provider.ts +++ b/server/modules/providers/list/gemini/gemini-skills.provider.ts @@ -33,4 +33,12 @@ export class GeminiSkillsProvider extends SkillsProvider { }, ]; } + + protected async getGlobalSkillSource(): Promise { + return { + scope: 'user', + rootDir: path.join(os.homedir(), '.gemini', 'skills'), + commandPrefix: '/', + }; + } } diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index ec76a7db..9b9fb576 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -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,104 @@ 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; + const rawEntries = Array.isArray(body.entries) + ? body.entries + : typeof body.content === 'string' + ? [{ + content: body.content, + directoryName: body.directoryName, + fileName: body.fileName, + files: body.files, + }] + : 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; + 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; + 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 +420,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', diff --git a/server/modules/providers/services/skills.service.ts b/server/modules/providers/services/skills.service.ts index 2a02ad22..9bb1c052 100644 --- a/server/modules/providers/services/skills.service.ts +++ b/server/modules/providers/services/skills.service.ts @@ -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 { + const provider = providerRegistry.resolveProvider(providerName); + return provider.skills.addSkills(input); + }, }; diff --git a/server/modules/providers/shared/skills/skills.provider.ts b/server/modules/providers/shared/skills/skills.provider.ts index 07e83a5b..20e7b3c5 100644 --- a/server/modules/providers/shared/skills/skills.provider.ts +++ b/server/modules/providers/shared/skills/skills.provider.ts @@ -1,20 +1,86 @@ import path from 'node:path'; +import { mkdir, rm, 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, '') +); + +type PendingSkillInstall = { + skillDirectoryPath: string; + skillPath: string; + content: string; + supportingFiles: Array<{ + targetPath: string; + content: string | Buffer; + }>; + skill: ProviderSkill; +}; + +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 +126,119 @@ export abstract class SkillsProvider implements IProviderSkills { return skills; } + async addSkills(input: ProviderSkillCreateInput): Promise { + 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, + }); + } + + const seenSkillPaths = new Set(); + const pendingInstalls: PendingSkillInstall[] = []; + + 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 = normalizeSkillDirectoryName( + 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(); + 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); + } + + const command = globalSkillSource.commandForSkill + ? globalSkillSource.commandForSkill(definition.name) + : `${globalSkillSource.commandPrefix ?? '/'}${definition.name}`; + + pendingInstalls.push({ + skillDirectoryPath, + skillPath, + content, + supportingFiles, + skill: { + provider: this.provider, + name: definition.name, + description: definition.description, + command, + scope: globalSkillSource.scope, + sourcePath: skillPath, + pluginName: globalSkillSource.pluginName, + pluginId: globalSkillSource.pluginId, + }, + }); + } + + for (const install of pendingInstalls) { + // Replace the complete skill directory so removed scripts or assets do not remain stale. + await rm(install.skillDirectoryPath, { recursive: true, force: true }); + await mkdir(install.skillDirectoryPath, { recursive: true }); + await writeFile(install.skillPath, `${install.content}\n`, 'utf8'); + for (const file of install.supportingFiles) { + await mkdir(path.dirname(file.targetPath), { recursive: true }); + await writeFile(file.targetPath, file.content); + } + } + + return pendingInstalls.map((install) => install.skill); + } + protected abstract getSkillSources(workspacePath: string): Promise; + + protected async getGlobalSkillSource(): Promise { + return null; + } } diff --git a/server/modules/providers/tests/skills.test.ts b/server/modules/providers/tests/skills.test.ts index 2c32c67b..79a3d9af 100644 --- a/server/modules/providers/tests/skills.test.ts +++ b/server/modules/providers/tests/skills.test.ts @@ -510,3 +510,195 @@ 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: [ + { + directoryName: 'uploaded-codex-folder', + 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', 'uploaded-codex-folder', 'SKILL.md')), + true, + ); + assert.equal( + await fs.readFile(path.join(path.dirname(createdCodexSkill.sourcePath), 'scripts', 'run.js'), 'utf8'), + 'console.log("codex skill");\n', + ); + + const fallbackNamedSkills = await providerSkillsService.addProviderSkills('codex', { + entries: [ + { + fileName: 'fallback / skill.md', + content: '---\ndescription: Normalized fallback skill\n---\n\nFallback body.\n', + }, + ], + }); + const fallbackNamedSkill = fallbackNamedSkills[0]; + assert.ok(fallbackNamedSkill); + assert.equal(fallbackNamedSkill.name, 'fallback-skill'); + assert.equal(fallbackNamedSkill.command, '$fallback-skill'); + assert.equal( + fallbackNamedSkill.sourcePath.endsWith(path.join('.agents', 'skills', 'fallback-skill', 'SKILL.md')), + true, + ); + + const replacedCodexSkills = await providerSkillsService.addProviderSkills('codex', { + entries: [ + { + directoryName: 'uploaded-codex-folder', + content: '---\nname: replacement\ndescription: Replacement skill\n---\n\nReplacement body.\n', + }, + ], + }); + assert.equal(replacedCodexSkills[0]?.command, '$replacement'); + assert.match(await fs.readFile(createdCodexSkill.sourcePath, 'utf8'), /Replacement body\./); + await assert.rejects( + fs.stat(path.join(path.dirname(createdCodexSkill.sourcePath), 'scripts', 'run.js')), + { code: 'ENOENT' }, + ); + + const pendingBatchSkillPath = path.join(tempRoot, '.agents', 'skills', 'pending-batch', 'SKILL.md'); + await assert.rejects( + providerSkillsService.addProviderSkills('codex', { + entries: [ + { + directoryName: 'pending-batch', + content: '---\nname: pending-batch\n---\n\nPending body.\n', + }, + { + directoryName: 'pending-batch', + content: '---\nname: duplicate-batch\n---\n\nDuplicate body.\n', + }, + ], + }), + /duplicate skill target/i, + ); + await assert.rejects(fs.stat(pendingBatchSkillPath), { code: 'ENOENT' }); + + 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 === 'replacement'), 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, + ); +}); diff --git a/server/shared/interfaces.ts b/server/shared/interfaces.ts index 1864fd02..ff5974c4 100644 --- a/server/shared/interfaces.ts +++ b/server/shared/interfaces.ts @@ -12,6 +12,7 @@ import type { ProviderModelsDefinition, ProviderMcpServer, ProviderSessionActiveModelChange, + ProviderSkillCreateInput, UpsertProviderMcpServerInput, } from '@/shared/types.js'; @@ -101,6 +102,15 @@ export interface IProviderSkills { * Lists all skills visible to this provider for the optional workspace. */ listSkills(options?: ProviderSkillListOptions): Promise; + + /** + * Writes one or more global user-scoped skills for this provider. + * + * Implementations should install the supplied markdown entries into the + * provider's writable user skill folder and return the normalized skill + * records that were written. + */ + addSkills(input: ProviderSkillCreateInput): Promise; } // --------------------------- diff --git a/server/shared/types.ts b/server/shared/types.ts index 91c477a6..94f6d7f9 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -320,6 +320,47 @@ export type ProviderSkillListOptions = { workspacePath?: string; }; +/** + * One supporting file bundled with an uploaded provider skill. + * + * `relativePath` is resolved below the installed skill directory and must never + * be absolute or contain traversal segments. Text files may use `utf8`; binary + * scripts and assets should use `base64` so JSON transport does not corrupt + * their bytes. + */ +export type ProviderSkillCreateFile = { + relativePath: string; + content: string; + encoding: 'utf8' | 'base64'; +}; + +/** + * One skill markdown payload submitted for provider-managed installation. + * + * `content` is the raw markdown body that will be written to `SKILL.md`. + * `directoryName` lets callers control the target folder name explicitly when + * they want stable filesystem paths that differ from the markdown front matter + * `name` field. `fileName` is optional upload metadata used only as a final + * fallback when no directory name or front matter name is present. `files` + * carries scripts, references, and other files from a complete skill folder. + */ +export type ProviderSkillCreateEntry = { + content: string; + directoryName?: string; + fileName?: string; + files?: ProviderSkillCreateFile[]; +}; + +/** + * Shared input accepted by provider skill creation operations. + * + * The service layer batches multiple skill definitions in one request. Each + * entry can contain only markdown or a complete skill folder. + */ +export type ProviderSkillCreateInput = { + entries: ProviderSkillCreateEntry[]; +}; + /** * Normalized skill record returned by provider skill adapters. * diff --git a/server/shared/utils.ts b/server/shared/utils.ts index 65fd4c22..503b21a8 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -957,9 +957,25 @@ export async function readProviderSkillMarkdownDefinition( skillPath: string, ): Promise<{ name: string; description: string }> { const content = await readFile(skillPath, 'utf8'); + return readProviderSkillMarkdownDefinitionFromContent( + content, + path.basename(path.dirname(skillPath)), + ); +} + +/** + * Reads the `name` and `description` fields from raw skill markdown content. + * + * This keeps filesystem discovery and newly uploaded skill creation aligned on + * the same front matter parsing rules. `fallbackName` is used when the markdown + * omits a `name` field so callers still get a stable, non-empty skill id. + */ +export function readProviderSkillMarkdownDefinitionFromContent( + content: string, + fallbackName: string, +): { name: string; description: string } { const parsed = parseFrontMatter(content); const data = readObjectRecord(parsed.data) ?? {}; - const fallbackName = path.basename(path.dirname(skillPath)); return { name: readOptionalString(data.name) ?? fallbackName, diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index 672be1ee..74c3d309 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -5,7 +5,7 @@ import type { ProviderAuthStatus } from '../../provider-auth/types'; export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about'; export type AgentProvider = LLMProvider; -export type AgentCategory = 'account' | 'permissions' | 'mcp'; +export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills'; export type ProjectSortOrder = 'name' | 'date'; export type SaveStatus = 'success' | 'error' | null; export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions'; diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 800440e0..bfa98edf 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -1,5 +1,6 @@ import { X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; + import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal'; import { Button } from '../../../shared/view/ui'; import SettingsSidebar from '../view/SettingsSidebar'; @@ -101,12 +102,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set {/* Body: sidebar + content */} -
+
{/* Content */} -
-
+
+
{activeTab === 'appearance' && ( ('claude'); const [selectedCategory, setSelectedCategory] = useState('account'); + const visibleCategories = useMemo(() => ( + selectedAgent === 'opencode' + ? ['account', 'permissions', 'mcp'] + : ['account', 'permissions', 'mcp', 'skills'] + ), [selectedAgent]); const visibleAgents = useMemo(() => { return ['claude', 'cursor', 'codex', 'gemini', 'opencode']; @@ -57,8 +62,14 @@ export default function AgentsSettingsTab({ providerAuthStatus.opencode, ]); + useEffect(() => { + if (!visibleCategories.includes(selectedCategory)) { + setSelectedCategory(visibleCategories[0] ?? 'account'); + } + }, [selectedCategory, visibleCategories]); + return ( -
+
-
+
diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx index 6f9fee5a..5bf0de2a 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx @@ -1,6 +1,8 @@ import type { AgentCategoryContentSectionProps } from '../types'; import type { McpProject } from '../../../../../mcp/types'; import { McpServers } from '../../../../../mcp'; +import type { SkillsProject } from '../../../../../skills/types'; +import { ProviderSkills } from '../../../../../skills'; import AccountContent from './content/AccountContent'; import PermissionsContent from './content/PermissionsContent'; @@ -18,7 +20,7 @@ export default function AgentCategoryContentSection({ projects, }: AgentCategoryContentSectionProps) { return ( -
+
{selectedCategory === 'account' && ( )} + + {selectedCategory === 'skills' && selectedAgent !== 'opencode' && ( + ((project) => ({ + projectId: project.name, + displayName: project.displayName, + fullPath: project.fullPath, + path: project.path, + }))} + /> + )}
); } diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryTabsSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryTabsSection.tsx index e6d68182..d1f67006 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryTabsSection.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryTabsSection.tsx @@ -1,11 +1,11 @@ import { useTranslation } from 'react-i18next'; + import { cn } from '../../../../../../lib/utils'; -import type { AgentCategory } from '../../../../types/types'; import type { AgentCategoryTabsSectionProps } from '../types'; -const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp']; - export default function AgentCategoryTabsSection({ + categories, + selectedAgent, selectedCategory, onSelectCategory, }: AgentCategoryTabsSectionProps) { @@ -14,7 +14,7 @@ export default function AgentCategoryTabsSection({ return (
- {AGENT_CATEGORIES.map((category) => ( + {categories.map((category) => (
diff --git a/src/components/settings/view/tabs/agents-settings/types.ts b/src/components/settings/view/tabs/agents-settings/types.ts index 6956a68c..731ce8d3 100644 --- a/src/components/settings/view/tabs/agents-settings/types.ts +++ b/src/components/settings/view/tabs/agents-settings/types.ts @@ -32,6 +32,8 @@ export type AgentsSettingsTabProps = { }; export type AgentCategoryTabsSectionProps = { + categories: AgentCategory[]; + selectedAgent: AgentProvider; selectedCategory: AgentCategory; onSelectCategory: (category: AgentCategory) => void; }; diff --git a/src/components/skills/hooks/useProviderSkills.ts b/src/components/skills/hooks/useProviderSkills.ts new file mode 100644 index 00000000..4655acd5 --- /dev/null +++ b/src/components/skills/hooks/useProviderSkills.ts @@ -0,0 +1,348 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { authenticatedFetch } from '../../../utils/api'; +import type { + ApiResponse, + ProviderSkill, + ProviderSkillCreatePayload, + ProviderSkillsResponse, + SkillsProject, + SkillsProvider, + SkillsScope, +} from '../types'; + +type SkillsCacheEntry = { + skills: ProviderSkill[]; + updatedAt: number; +}; + +type ProjectTarget = { + projectId: string; + displayName: string; + path: string; +}; + +const SKILLS_CACHE_TTL_MS = 5 * 60_000; +const skillsCache = new Map(); + +const SKILL_SCOPE_ORDER: Record = { + user: 0, + plugin: 1, + repo: 2, + project: 3, + admin: 4, + system: 5, +}; + +const toResponseJson = async (response: Response): Promise => response.json() as Promise; + +const getApiErrorMessage = (payload: unknown, fallback: string): string => { + if (!payload || typeof payload !== 'object') { + return fallback; + } + + const record = payload as Record; + const error = record.error; + if (error && typeof error === 'object') { + const message = (error as Record).message; + if (typeof message === 'string' && message.trim()) { + return message; + } + } + + if (typeof error === 'string' && error.trim()) { + return error; + } + + const details = record.details; + if (typeof details === 'string' && details.trim()) { + return details; + } + + return fallback; +}; + +const isSkillsScope = (value: unknown): value is SkillsScope => ( + value === 'user' + || value === 'project' + || value === 'plugin' + || value === 'repo' + || value === 'admin' + || value === 'system' +); + +const normalizeScope = (value: unknown): SkillsScope => ( + isSkillsScope(value) ? value : 'user' +); + +const createProjectTargets = (projects: SkillsProject[]): ProjectTarget[] => { + const seenPaths = new Set(); + + return projects.reduce((acc, project) => { + const projectPath = project.fullPath || project.path || ''; + if (!projectPath || seenPaths.has(projectPath)) { + return acc; + } + + seenPaths.add(projectPath); + acc.push({ + projectId: project.projectId, + displayName: project.displayName || project.projectId, + path: projectPath, + }); + return acc; + }, []); +}; + +const normalizeSkill = ( + provider: SkillsProvider, + skill: Partial, + project?: ProjectTarget, +): ProviderSkill => { + const scope = normalizeScope(skill.scope); + const shouldAttachProject = scope === 'project' || scope === 'repo'; + + return { + provider, + name: String(skill.name ?? ''), + description: String(skill.description ?? ''), + command: String(skill.command ?? ''), + scope, + sourcePath: String(skill.sourcePath ?? ''), + pluginName: typeof skill.pluginName === 'string' ? skill.pluginName : undefined, + pluginId: typeof skill.pluginId === 'string' ? skill.pluginId : undefined, + projectDisplayName: shouldAttachProject + ? project?.displayName ?? skill.projectDisplayName + : skill.projectDisplayName, + projectPath: shouldAttachProject + ? project?.path ?? skill.projectPath + : skill.projectPath, + }; +}; + +const getSkillIdentity = (skill: ProviderSkill): string => ( + [ + skill.provider, + skill.scope, + skill.command, + skill.sourcePath || 'no-source-path', + skill.projectPath || 'global', + ].join(':') +); + +const sortSkills = (skills: ProviderSkill[]): ProviderSkill[] => ( + [...skills].sort((left, right) => { + const scopeDelta = SKILL_SCOPE_ORDER[left.scope] - SKILL_SCOPE_ORDER[right.scope]; + if (scopeDelta !== 0) { + return scopeDelta; + } + + const projectDelta = (left.projectDisplayName || '').localeCompare(right.projectDisplayName || ''); + if (projectDelta !== 0) { + return projectDelta; + } + + return left.command.localeCompare(right.command); + }) +); + +const mergeSkills = ( + existingSkills: ProviderSkill[], + incomingSkills: ProviderSkill[], +): ProviderSkill[] => { + const skillsById = new Map(); + existingSkills.forEach((skill) => { + skillsById.set(getSkillIdentity(skill), skill); + }); + incomingSkills.forEach((skill) => { + skillsById.set(getSkillIdentity(skill), skill); + }); + + return sortSkills([...skillsById.values()]); +}; + +const fetchProviderSkills = async ( + provider: SkillsProvider, + project?: ProjectTarget, +): Promise => { + const params = new URLSearchParams(); + if (project?.path) { + params.set('workspacePath', project.path); + } + + const response = await authenticatedFetch( + `/api/providers/${provider}/skills${params.toString() ? `?${params.toString()}` : ''}`, + ); + const data = await toResponseJson>(response); + if (!response.ok || !data.success) { + throw new Error(getApiErrorMessage(data, `Failed to load ${provider} skills`)); + } + + return (data.data.skills || []).map((skill) => normalizeSkill(provider, skill, project)); +}; + +const saveProviderSkills = async ( + provider: SkillsProvider, + payload: ProviderSkillCreatePayload, +): Promise => { + const response = await authenticatedFetch(`/api/providers/${provider}/skills`, { + method: 'POST', + body: JSON.stringify(payload), + }); + const data = await toResponseJson>(response); + if (!response.ok || !data.success) { + throw new Error(getApiErrorMessage(data, 'Failed to save skills')); + } + + return (data.data.skills || []).map((skill) => normalizeSkill(provider, skill)); +}; + +const getCacheKey = (provider: SkillsProvider, projects: ProjectTarget[]): string => { + const projectKey = projects.map((project) => project.path).sort().join('|'); + return `${provider}:${projectKey}`; +}; + +const clearProviderSkillCache = (provider: SkillsProvider): void => { + for (const cacheKey of [...skillsCache.keys()]) { + if (cacheKey.startsWith(`${provider}:`)) { + skillsCache.delete(cacheKey); + } + } +}; + +type UseProviderSkillsArgs = { + selectedProvider: SkillsProvider; + currentProjects: SkillsProject[]; +}; + +export function useProviderSkills({ selectedProvider, currentProjects }: UseProviderSkillsArgs) { + const [skills, setSkills] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false); + const [loadError, setLoadError] = useState(null); + const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null); + const activeLoadIdRef = useRef(0); + + const projectTargets = useMemo(() => createProjectTargets(currentProjects), [currentProjects]); + const cacheKey = useMemo(() => getCacheKey(selectedProvider, projectTargets), [projectTargets, selectedProvider]); + + const refreshSkills = useCallback(async (options: { force?: boolean } = {}) => { + const loadId = activeLoadIdRef.current + 1; + activeLoadIdRef.current = loadId; + + const cachedEntry = skillsCache.get(cacheKey); + const canUseCache = !options.force && cachedEntry && Date.now() - cachedEntry.updatedAt < SKILLS_CACHE_TTL_MS; + if (canUseCache) { + setSkills(cachedEntry.skills); + setIsLoading(false); + setIsLoadingProjectScopes(false); + setLoadError(null); + return; + } + + if (cachedEntry && !options.force) { + setSkills(cachedEntry.skills); + } else { + setSkills([]); + } + + setIsLoading(!cachedEntry); + setIsLoadingProjectScopes(false); + setLoadError(null); + + let nextSkills = cachedEntry && !options.force ? cachedEntry.skills : []; + let firstError: string | null = null; + + try { + const globalSkills = await fetchProviderSkills(selectedProvider); + if (activeLoadIdRef.current !== loadId) { + return; + } + + nextSkills = mergeSkills(nextSkills, globalSkills); + setSkills(nextSkills); + } catch (error) { + firstError = error instanceof Error ? error.message : 'Failed to load skills'; + } + + if (activeLoadIdRef.current !== loadId) { + return; + } + + setIsLoading(false); + + if (projectTargets.length === 0) { + const finalSkills = sortSkills(nextSkills); + skillsCache.set(cacheKey, { skills: finalSkills, updatedAt: Date.now() }); + setSkills(finalSkills); + setLoadError(firstError); + return; + } + + setIsLoadingProjectScopes(true); + + await Promise.all(projectTargets.map(async (project) => { + try { + const projectSkills = await fetchProviderSkills(selectedProvider, project); + if (activeLoadIdRef.current !== loadId) { + return; + } + + nextSkills = mergeSkills(nextSkills, projectSkills); + setSkills(nextSkills); + } catch (error) { + firstError = firstError || (error instanceof Error ? error.message : 'Failed to load skills'); + } + })); + + if (activeLoadIdRef.current !== loadId) { + return; + } + + const finalSkills = sortSkills(nextSkills); + skillsCache.set(cacheKey, { skills: finalSkills, updatedAt: Date.now() }); + setSkills(finalSkills); + setLoadError(firstError); + setIsLoadingProjectScopes(false); + }, [cacheKey, projectTargets, selectedProvider]); + + const addSkills = useCallback(async (payload: ProviderSkillCreatePayload) => { + try { + const createdSkills = await saveProviderSkills(selectedProvider, payload); + clearProviderSkillCache(selectedProvider); + await refreshSkills({ force: true }); + setSaveStatus('success'); + return createdSkills; + } catch (error) { + setSaveStatus('error'); + throw error; + } + }, [refreshSkills, selectedProvider]); + + useEffect(() => { + void refreshSkills(); + }, [refreshSkills]); + + useEffect(() => { + setSaveStatus(null); + }, [selectedProvider]); + + useEffect(() => { + if (saveStatus === null) { + return; + } + + const timer = window.setTimeout(() => setSaveStatus(null), 6000); + return () => window.clearTimeout(timer); + }, [saveStatus]); + + return { + skills, + isLoading, + isLoadingProjectScopes, + loadError, + saveStatus, + addSkills, + refreshSkills, + }; +} diff --git a/src/components/skills/index.ts b/src/components/skills/index.ts new file mode 100644 index 00000000..75b9e9ee --- /dev/null +++ b/src/components/skills/index.ts @@ -0,0 +1 @@ +export { default as ProviderSkills } from './view/ProviderSkills'; diff --git a/src/components/skills/types.ts b/src/components/skills/types.ts new file mode 100644 index 00000000..cbebe582 --- /dev/null +++ b/src/components/skills/types.ts @@ -0,0 +1,60 @@ +import type { LLMProvider } from '../../types/app'; + +export type SkillsProvider = LLMProvider; +export type SkillsScope = 'user' | 'project' | 'plugin' | 'repo' | 'admin' | 'system'; + +export type SkillsProject = { + projectId: string; + displayName?: string; + fullPath?: string; + path?: string; +}; + +export type ProviderSkill = { + provider: SkillsProvider; + name: string; + description: string; + command: string; + scope: SkillsScope; + sourcePath: string; + pluginName?: string; + pluginId?: string; + projectDisplayName?: string; + projectPath?: string; +}; + +export type ProviderSkillCreateEntryPayload = { + content: string; + directoryName?: string; + fileName?: string; + files?: Array<{ + relativePath: string; + content: string; + encoding: 'base64'; + }>; +}; + +export type ProviderSkillCreatePayload = { + entries: ProviderSkillCreateEntryPayload[]; +}; + +export type ProviderSkillsResponse = { + provider: SkillsProvider; + skills: Array>; +}; + +export type ApiSuccessResponse = { + success: true; + data: T; +}; + +export type ApiErrorResponse = { + success: false; + error?: { + code?: string; + message?: string; + details?: unknown; + }; +}; + +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; diff --git a/src/components/skills/view/ProviderSkills.tsx b/src/components/skills/view/ProviderSkills.tsx new file mode 100644 index 00000000..186b6d35 --- /dev/null +++ b/src/components/skills/view/ProviderSkills.tsx @@ -0,0 +1,654 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { + CheckCircle2, + FileCode2, + FileText, + FileUp, + FolderUp, + Loader2, + RefreshCw, + Search, + Upload, + X, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { cn } from '../../../lib/utils'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, +} from '../../../shared/view/ui'; +import { useProviderSkills } from '../hooks/useProviderSkills'; +import type { + ProviderSkill, + ProviderSkillCreateEntryPayload, + SkillsProject, + SkillsProvider, + SkillsScope, +} from '../types'; + +type ProviderSkillsProps = { + selectedProvider: SkillsProvider; + currentProjects: SkillsProject[]; +}; + +type QueuedSkillSourceFile = { + file: File; + relativePath: string; +}; + +type QueuedSkillFile = { + id: string; + name: string; + size: number; + kind: 'markdown' | 'folder'; + skillFile: File; + files: QueuedSkillSourceFile[]; +}; + +const MAX_SKILL_FOLDER_FILES = 500; +const MAX_SKILL_FOLDER_BYTES = 30 * 1024 * 1024; + +const PROVIDER_NAMES: Record = { + claude: 'Claude', + codex: 'Codex', + cursor: 'Cursor', + gemini: 'Gemini', + opencode: 'OpenCode', +}; + +const PROVIDER_SKILL_PATHS: Record, string> = { + claude: '~/.claude/skills//SKILL.md', + codex: '~/.agents/skills//SKILL.md', + cursor: '~/.cursor/skills//SKILL.md', + gemini: '~/.gemini/skills//SKILL.md', +}; + +const SCOPE_LABELS: Record = { + user: 'User', + plugin: 'Plugin', + repo: 'Repo', + project: 'Project', + admin: 'Admin', + system: 'System', +}; + +const SCOPE_BADGE_CLASSES: Record = { + user: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', + plugin: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300', + repo: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300', + project: 'border-orange-500/30 bg-orange-500/10 text-orange-700 dark:text-orange-300', + admin: 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300', + system: 'border-slate-500/30 bg-slate-500/10 text-slate-700 dark:text-slate-300', +}; + +const SCOPE_ORDER: SkillsScope[] = ['user', 'plugin', 'repo', 'project', 'admin', 'system']; + +const groupSkillsByScope = (skills: ProviderSkill[]): Array<{ scope: SkillsScope; skills: ProviderSkill[] }> => ( + SCOPE_ORDER + .map((scope) => ({ scope, skills: skills.filter((skill) => skill.scope === scope) })) + .filter((group) => group.skills.length > 0) +); + +const formatFileSize = (size: number): string => { + if (size < 1024) { + return `${size} B`; + } + + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB`; + } + + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +}; + +const getBrowserRelativePath = (file: File): string => { + const fileWithRelativePath = file as File & { + path?: string; + webkitRelativePath?: string; + }; + return ( + fileWithRelativePath.webkitRelativePath + || fileWithRelativePath.path + || file.name + ) + .replace(/\\/g, '/') + .replace(/^\.\/+/, '') + .replace(/^\/+/, ''); +}; + +const getParentPath = (filePath: string): string => { + const separatorIndex = filePath.lastIndexOf('/'); + return separatorIndex >= 0 ? filePath.slice(0, separatorIndex) : ''; +}; + +const getBaseName = (filePath: string): string => { + const segments = filePath.split('/').filter(Boolean); + return segments.at(-1) || 'skill'; +}; + +const readFileAsBase64 = (file: File): Promise => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : ''; + const separatorIndex = result.indexOf(','); + resolve(separatorIndex >= 0 ? result.slice(separatorIndex + 1) : result); + }; + reader.onerror = () => reject(reader.error ?? new Error(`Failed to read ${file.name}`)); + reader.readAsDataURL(file); +}); + +const buildQueuedSkillFolders = (selectedFiles: File[]): QueuedSkillFile[] => { + if (selectedFiles.length > MAX_SKILL_FOLDER_FILES) { + throw new Error(`A skill folder can contain up to ${MAX_SKILL_FOLDER_FILES} files.`); + } + + const totalSize = selectedFiles.reduce((size, file) => size + file.size, 0); + if (totalSize > MAX_SKILL_FOLDER_BYTES) { + throw new Error('Selected skill folders must be smaller than 30 MB in total.'); + } + + const files = selectedFiles.map((file) => ({ + file, + relativePath: getBrowserRelativePath(file), + })); + const skillRoots = files + .filter(({ relativePath }) => getBaseName(relativePath).toLowerCase() === 'skill.md') + .map(({ relativePath }) => getParentPath(relativePath)) + .sort((left, right) => right.length - left.length); + + if (skillRoots.length === 0) { + throw new Error('The selected folder does not contain a SKILL.md file.'); + } + + return skillRoots.map((skillRoot) => { + const skillFiles = files.filter(({ relativePath }) => { + const owningRoot = skillRoots.find((candidateRoot) => { + const normalizedRelativePath = relativePath.toLowerCase(); + const normalizedSkillPath = `${candidateRoot}/skill.md`.toLowerCase(); + return normalizedRelativePath === normalizedSkillPath + || relativePath.startsWith(`${candidateRoot}/`); + }); + return owningRoot === skillRoot; + }); + const skillSourceFile = skillFiles.find( + ({ relativePath }) => ( + relativePath.toLowerCase() === `${skillRoot}/skill.md`.toLowerCase() + ), + ); + if (!skillSourceFile) { + throw new Error(`Could not read SKILL.md from ${getBaseName(skillRoot)}.`); + } + + return { + id: `folder:${skillRoot}:${skillFiles.map(({ file }) => file.lastModified).join(':')}`, + name: getBaseName(skillRoot), + size: skillFiles.reduce((size, { file }) => size + file.size, 0), + kind: 'folder' as const, + skillFile: skillSourceFile.file, + files: skillFiles.map(({ file, relativePath }) => ({ + file, + relativePath: skillRoot ? relativePath.slice(skillRoot.length + 1) : relativePath, + })), + }; + }); +}; + +export default function ProviderSkills({ selectedProvider, currentProjects }: ProviderSkillsProps) { + const { t } = useTranslation('settings'); + const { + skills, + isLoading, + isLoadingProjectScopes, + loadError, + saveStatus, + addSkills, + refreshSkills, + } = useProviderSkills({ selectedProvider, currentProjects }); + const [queuedFiles, setQueuedFiles] = useState([]); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const fileInputRef = useRef(null); + const folderInputRef = useRef(null); + + const providerName = PROVIDER_NAMES[selectedProvider]; + const providerPath = selectedProvider === 'opencode' ? null : PROVIDER_SKILL_PATHS[selectedProvider]; + + useEffect(() => { + setQueuedFiles([]); + setSubmitError(null); + setIsSubmitting(false); + setSearchQuery(''); + }, [selectedProvider]); + + useEffect(() => { + folderInputRef.current?.setAttribute('webkitdirectory', ''); + folderInputRef.current?.setAttribute('directory', ''); + }, []); + + const filteredSkills = useMemo(() => { + const normalizedQuery = searchQuery.trim().toLocaleLowerCase(); + if (!normalizedQuery) { + return skills; + } + + return skills.filter((skill) => ( + [ + skill.command, + skill.name, + skill.description, + skill.scope, + skill.pluginName, + skill.projectDisplayName, + skill.sourcePath, + ] + .filter(Boolean) + .some((value) => value?.toLocaleLowerCase().includes(normalizedQuery)) + )); + }, [searchQuery, skills]); + + const groupedSkills = useMemo(() => groupSkillsByScope(filteredSkills), [filteredSkills]); + + const queueSkillFolders = useCallback((selectedFiles: File[]) => { + const queuedFolders = buildQueuedSkillFolders(selectedFiles); + setQueuedFiles((previous) => { + const nextMap = new Map(previous.map((file) => [file.id, file])); + queuedFolders.forEach((folder) => nextMap.set(folder.id, folder)); + return [...nextMap.values()].slice(0, 20); + }); + }, []); + + const handleDrop = useCallback((files: File[]) => { + const includesDirectory = files.some((file) => getBrowserRelativePath(file).includes('/')); + if (includesDirectory) { + try { + queueSkillFolders(files); + setSubmitError(null); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : 'Failed to read skill folder'); + } + return; + } + + const acceptedFiles = files + .filter((file) => file.name.toLowerCase().endsWith('.md')) + .slice(0, 20); + + if (acceptedFiles.length === 0) { + setSubmitError('Drop one or more markdown files or a folder containing SKILL.md.'); + return; + } + + setQueuedFiles((previous) => { + const nextMap = new Map(previous.map((file) => [file.id, file])); + acceptedFiles.forEach((file) => { + const id = `${file.name}:${file.size}:${file.lastModified}`; + nextMap.set(id, { + id, + name: file.name, + size: file.size, + kind: 'markdown', + skillFile: file, + files: [{ file, relativePath: 'SKILL.md' }], + }); + }); + + return [...nextMap.values()].slice(0, 20); + }); + setSubmitError(null); + }, [queueSkillFolders]); + + const handleFolderSelection = useCallback((selectedFiles: File[]) => { + if (selectedFiles.length === 0) { + return; + } + + try { + queueSkillFolders(selectedFiles); + setSubmitError(null); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : 'Failed to read skill folder'); + } + }, [queueSkillFolders]); + + const { getRootProps, isDragActive } = useDropzone({ + maxFiles: MAX_SKILL_FOLDER_FILES, + noClick: true, + noKeyboard: true, + onDrop: handleDrop, + }); + + const handleUploadInstall = useCallback(async () => { + if (queuedFiles.length === 0) { + setSubmitError('Add one or more markdown files first.'); + return; + } + + setIsSubmitting(true); + setSubmitError(null); + + try { + const entries = await Promise.all(queuedFiles.map(async (queuedFile) => ({ + fileName: queuedFile.kind === 'folder' ? `${queuedFile.name}.md` : queuedFile.name, + directoryName: queuedFile.kind === 'folder' ? queuedFile.name : undefined, + content: await queuedFile.skillFile.text(), + files: queuedFile.kind === 'folder' + ? await Promise.all( + queuedFile.files + .filter(({ relativePath }) => relativePath.toLowerCase() !== 'skill.md') + .map(async ({ file, relativePath }) => ({ + relativePath, + content: await readFileAsBase64(file), + encoding: 'base64' as const, + })), + ) + : undefined, + }))); + await addSkills({ entries }); + setQueuedFiles([]); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : 'Failed to import skills'); + } finally { + setIsSubmitting(false); + } + }, [addSkills, queuedFiles]); + + return ( +
+
+
+
+ +
+
+

{t('tabs.skills', { defaultValue: 'Skills' })}

+

+ Install global {providerName} skills from `.md` files or complete skill folders. +

+
+
+ + +
+ + + +
+
Upload Skills
+
+
Install Path
+ {providerPath} +
+
+
+ + +
+
+ { + handleDrop(Array.from(event.target.files ?? [])); + event.target.value = ''; + }} + /> + { + handleFolderSelection(Array.from(event.target.files ?? [])); + event.target.value = ''; + }} + /> +
+ +
+
Drop `.md` files or skill folders here
+
+ Upload standalone definitions or choose a full folder to include its scripts, references, and assets. +
+
+
+ + +
+
+
+ + {queuedFiles.length > 0 && ( +
+
Queued Files
+
+ {queuedFiles.map((queuedFile) => ( +
+
+
{queuedFile.name}
+
+ {queuedFile.kind === 'folder' + ? `${queuedFile.files.length} files` + : 'Markdown file'} + {' · '} + {formatFileSize(queuedFile.size)} +
+
+ +
+ ))} +
+
+ )} + +
+ + + Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`. + +
+
+ + {(submitError || loadError) && ( +
+ {submitError || loadError} +
+ )} + + {saveStatus === 'success' && ( +
+ + Skills saved successfully. +
+ )} +
+
+ + + +
+
+ Visible Skills + + The list below comes from the provider skill discovery API and includes global and project-aware locations. + +
+
+ + setSearchQuery(event.target.value)} + placeholder="Search skills..." + aria-label="Search visible skills" + className="h-9 w-full pl-9 pr-9" + /> + {searchQuery && ( + + )} +
+ {isLoadingProjectScopes && ( +
+ + Scanning project skills… +
+ )} +
+
+ + + {isLoading && skills.length === 0 && ( +
+ Loading {providerName} skills… +
+ )} + + {!isLoading && skills.length === 0 && ( +
+
+ +
+
No skills discovered yet
+
+ Add a global skill above or create project-specific skill folders in your workspace. +
+
+ )} + + {!isLoading && skills.length > 0 && filteredSkills.length === 0 && ( +
+ +
No matching skills
+
+ Try a different command, name, scope, project, or source path. +
+
+ )} + + {groupedSkills.map((group) => ( +
+
+ + {SCOPE_LABELS[group.scope]} + + + {group.skills.length} skill{group.skills.length === 1 ? '' : 's'} + +
+ +
+ {group.skills.map((skill) => ( +
+
+
{skill.command}
+
{skill.name}
+
+ +

+ {skill.description || 'No description provided in the skill front matter.'} +

+ +
+ {skill.pluginName && ( + + Plugin: {skill.pluginName} + + )} + {skill.projectDisplayName && ( + + Project: {skill.projectDisplayName} + + )} +
+ +
+
Source
+ {skill.sourcePath} +
+
+ ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index c9513adf..fbcd797a 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -4,6 +4,7 @@ "account": "Account", "permissions": "Permissions", "mcpServers": "MCP Servers", + "skills": "Skills", "appearance": "Appearance" }, "account": {