From be9fdd165e34e4cda04dbb84fe5eb316ba86f843 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:17:23 +0300 Subject: [PATCH] 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. --- .../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 | 109 ++++ .../providers/services/skills.service.ts | 17 +- .../shared/skills/skills.provider.ts | 159 +++++ server/modules/providers/tests/skills.test.ts | 141 ++++ 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 | 344 ++++++++++ src/components/skills/index.ts | 1 + src/components/skills/types.ts | 60 ++ src/components/skills/view/ProviderSkills.tsx | 602 ++++++++++++++++++ src/i18n/locales/en/settings.json | 1 + 22 files changed, 1578 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..74592beb 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,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; + 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; + 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 +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', 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..55f88184 100644 --- a/server/modules/providers/shared/skills/skills.provider.ts +++ b/server/modules/providers/shared/skills/skills.provider.ts @@ -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 { + 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(); + + 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(); + 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; + + 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..d3db9edf 100644 --- a/server/modules/providers/tests/skills.test.ts +++ b/server/modules/providers/tests/skills.test.ts @@ -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, + ); +}); 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..225ca3c2 --- /dev/null +++ b/src/components/skills/hooks/useProviderSkills.ts @@ -0,0 +1,344 @@ +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 : undefined, + projectPath: shouldAttachProject ? project?.path : undefined, + }; +}; + +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..6818cb7e --- /dev/null +++ b/src/components/skills/view/ProviderSkills.tsx @@ -0,0 +1,602 @@ +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 & { webkitRelativePath?: string }; + return (fileWithRelativePath.webkitRelativePath || file.name).replace(/\\/g, '/'); +}; + +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) => ( + relativePath === `${candidateRoot}/SKILL.md` + || relativePath.startsWith(`${candidateRoot}/`) + )); + return owningRoot === skillRoot; + }); + const skillSourceFile = skillFiles.find( + ({ relativePath }) => relativePath === `${skillRoot}/SKILL.md`, + ); + 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 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 handleDrop = useCallback((files: File[]) => { + const acceptedFiles = files + .filter((file) => file.name.toLowerCase().endsWith('.md')) + .slice(0, 20); + + 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); + }, []); + + const handleFolderSelection = useCallback((selectedFiles: File[]) => { + try { + 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); + }); + setSubmitError(null); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : 'Failed to read skill folder'); + } + }, []); + + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + accept: { + 'text/markdown': ['.md'], + 'text/plain': ['.md'], + }, + maxFiles: 20, + 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, + 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} +
+
+
+ + +
+
+ + { + handleFolderSelection(Array.from(event.target.files ?? [])); + event.target.value = ''; + }} + /> +
+ +
+
Drop `.md` files 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)} +
+
+ +
+ ))} +
+
+ )} + +
+ + + The skill folder name is taken from the `name` field 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": {