diff --git a/server/index.js b/server/index.js index f4c9e25e..d957ef58 100755 --- a/server/index.js +++ b/server/index.js @@ -76,6 +76,19 @@ const __dirname = getModuleDir(import.meta.url); // Resolving the app root once keeps every repo-level lookup below aligned across both layouts. const APP_ROOT = findAppRoot(__dirname); const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm'; +// Version of the code that is actually running, captured once at process +// startup. This intentionally does NOT re-read package.json per request: after +// an update replaces the files on disk, package.json reflects the NEW version +// while this long-lived process still runs the OLD code. The frontend bundle is +// rebuilt on update, so a mismatch between this value and the frontend's +// build-time version means the server was updated but not restarted. +const RUNNING_VERSION = (() => { + try { + return JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')).version || null; + } catch { + return null; + } +})(); const MAX_FILE_UPLOAD_SIZE_MB = 200; const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024; const MAX_FILE_UPLOAD_COUNT = 20; @@ -156,7 +169,8 @@ app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), - installMode + installMode, + version: RUNNING_VERSION }); }); 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/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index 15d96990..5e544b08 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -43,7 +43,7 @@ function Sidebar({ }: SidebarProps) { const { t } = useTranslation(['sidebar', 'common']); const { isPWA } = useDeviceSettings({ trackMobile: false }); - const { updateAvailable, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck( + const { updateAvailable, restartRequired, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck( 'siteboon', 'claudecodeui', ); @@ -224,6 +224,7 @@ function Sidebar({ onExpand={handleExpandSidebar} onShowSettings={onShowSettings} updateAvailable={updateAvailable} + restartRequired={restartRequired} onShowVersionModal={() => setShowVersionModal(true)} t={t} /> @@ -296,6 +297,7 @@ function Sidebar({ onCreateProject={() => setShowNewProject(true)} onCollapseSidebar={handleCollapseSidebar} updateAvailable={updateAvailable} + restartRequired={restartRequired} releaseInfo={releaseInfo} latestVersion={latestVersion} currentVersion={currentVersion} diff --git a/src/components/sidebar/view/subcomponents/SidebarCollapsed.tsx b/src/components/sidebar/view/subcomponents/SidebarCollapsed.tsx index 90a6338f..c4ae4300 100644 --- a/src/components/sidebar/view/subcomponents/SidebarCollapsed.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarCollapsed.tsx @@ -1,4 +1,4 @@ -import { Settings, Sparkles, PanelLeftOpen, Bug } from 'lucide-react'; +import { Settings, Sparkles, PanelLeftOpen, Bug, AlertTriangle } from 'lucide-react'; import type { TFunction } from 'i18next'; const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE'; @@ -16,6 +16,7 @@ type SidebarCollapsedProps = { onExpand: () => void; onShowSettings: () => void; updateAvailable: boolean; + restartRequired: boolean; onShowVersionModal: () => void; t: TFunction; }; @@ -24,6 +25,7 @@ export default function SidebarCollapsed({ onExpand, onShowSettings, updateAvailable, + restartRequired, onShowVersionModal, t, }: SidebarCollapsedProps) { @@ -75,6 +77,18 @@ export default function SidebarCollapsed({ + {/* Restart-required indicator */} + {restartRequired && ( +
+ + +
+ )} + {/* Update indicator */} {updateAvailable && ( +
+ + + +
+
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/hooks/useVersionCheck.ts b/src/hooks/useVersionCheck.ts index ba3a894f..96d0274c 100644 --- a/src/hooks/useVersionCheck.ts +++ b/src/hooks/useVersionCheck.ts @@ -28,20 +28,31 @@ export const useVersionCheck = (owner: string, repo: string) => { const [latestVersion, setLatestVersion] = useState(null); const [releaseInfo, setReleaseInfo] = useState(null); const [installMode, setInstallMode] = useState('git'); + const [runningVersion, setRunningVersion] = useState(null); + const [restartRequired, setRestartRequired] = useState(false); useEffect(() => { - const fetchInstallMode = async () => { + const fetchHealth = async () => { try { const response = await fetch('/health'); const data = await response.json(); if (data.installMode === 'npm' || data.installMode === 'git') { setInstallMode(data.installMode); } + // `data.version` is the version the server process is actually running. + // This module's `version` is baked into the frontend bundle at build + // time, so it reflects the installed (on-disk) package. If they differ, + // the package was updated but the server process was not restarted, and + // DB-backed actions may silently fail until it is. + if (typeof data.version === 'string' && data.version.length > 0) { + setRunningVersion(data.version); + setRestartRequired(data.version !== version); + } } catch { - // Default to git on error + // Default to git / no restart hint on error } }; - fetchInstallMode(); + fetchHealth(); }, []); useEffect(() => { @@ -84,5 +95,5 @@ export const useVersionCheck = (owner: string, repo: string) => { return () => clearInterval(interval); }, [owner, repo]); - return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode }; + return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode, runningVersion, restartRequired }; }; \ No newline at end of file diff --git a/src/i18n/locales/de/sidebar.json b/src/i18n/locales/de/sidebar.json index 6542c02a..d45c372f 100644 --- a/src/i18n/locales/de/sidebar.json +++ b/src/i18n/locales/de/sidebar.json @@ -115,7 +115,8 @@ "restoreSessionError": "Fehler beim Wiederherstellen der Sitzung. Bitte erneut versuchen." }, "version": { - "updateAvailable": "Update verfügbar" + "updateAvailable": "Update verfügbar", + "restartRequired": "Update installiert – zum Anwenden Server neu starten" }, "search": { "modeProjects": "Projekte", 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": { diff --git a/src/i18n/locales/en/sidebar.json b/src/i18n/locales/en/sidebar.json index e96c44e9..05783430 100644 --- a/src/i18n/locales/en/sidebar.json +++ b/src/i18n/locales/en/sidebar.json @@ -115,7 +115,8 @@ "restoreSessionError": "Error restoring session. Please try again." }, "version": { - "updateAvailable": "Update available" + "updateAvailable": "Update available", + "restartRequired": "Update installed — restart the server to apply" }, "search": { "modeProjects": "Projects", diff --git a/src/i18n/locales/fr/sidebar.json b/src/i18n/locales/fr/sidebar.json index 8f5ab901..682ca437 100644 --- a/src/i18n/locales/fr/sidebar.json +++ b/src/i18n/locales/fr/sidebar.json @@ -115,7 +115,8 @@ "restoreSessionError": "Erreur lors de la restauration de la session. Veuillez réessayer." }, "version": { - "updateAvailable": "Mise à jour disponible" + "updateAvailable": "Mise à jour disponible", + "restartRequired": "Mise à jour installée — redémarrez le serveur pour l'appliquer" }, "search": { "modeProjects": "Projets", diff --git a/src/i18n/locales/it/sidebar.json b/src/i18n/locales/it/sidebar.json index a09ab87a..f019b216 100644 --- a/src/i18n/locales/it/sidebar.json +++ b/src/i18n/locales/it/sidebar.json @@ -115,7 +115,8 @@ "restoreSessionError": "Errore durante il ripristino della sessione. Riprova." }, "version": { - "updateAvailable": "Aggiornamento disponibile" + "updateAvailable": "Aggiornamento disponibile", + "restartRequired": "Aggiornamento installato — riavvia il server per applicarlo" }, "search": { "modeProjects": "Progetti", diff --git a/src/i18n/locales/ja/sidebar.json b/src/i18n/locales/ja/sidebar.json index dc9d0534..e3d782ac 100644 --- a/src/i18n/locales/ja/sidebar.json +++ b/src/i18n/locales/ja/sidebar.json @@ -114,7 +114,8 @@ "restoreSessionError": "セッションの復元でエラーが発生しました。もう一度お試しください。" }, "version": { - "updateAvailable": "アップデートあり" + "updateAvailable": "アップデートあり", + "restartRequired": "更新が適用されていません。サーバーを再起動してください" }, "deleteConfirmation": { "deleteProject": "プロジェクトを除去", diff --git a/src/i18n/locales/ko/sidebar.json b/src/i18n/locales/ko/sidebar.json index 41b29378..6eaef5b2 100644 --- a/src/i18n/locales/ko/sidebar.json +++ b/src/i18n/locales/ko/sidebar.json @@ -114,7 +114,8 @@ "restoreSessionError": "세션 복원 오류. 다시 시도해주세요." }, "version": { - "updateAvailable": "업데이트 가능" + "updateAvailable": "업데이트 가능", + "restartRequired": "업데이트가 설치됨 — 적용하려면 서버를 재시작하세요" }, "deleteConfirmation": { "deleteProject": "프로젝트 제거", diff --git a/src/i18n/locales/ru/sidebar.json b/src/i18n/locales/ru/sidebar.json index 3798151b..8a85cf59 100644 --- a/src/i18n/locales/ru/sidebar.json +++ b/src/i18n/locales/ru/sidebar.json @@ -115,7 +115,8 @@ "restoreSessionError": "Ошибка при восстановлении сеанса. Попробуйте снова." }, "version": { - "updateAvailable": "Доступно обновление" + "updateAvailable": "Доступно обновление", + "restartRequired": "Обновление установлено — перезапустите сервер для применения" }, "search": { "modeProjects": "Проекты", diff --git a/src/i18n/locales/tr/sidebar.json b/src/i18n/locales/tr/sidebar.json index fefcfed6..2f6047ef 100644 --- a/src/i18n/locales/tr/sidebar.json +++ b/src/i18n/locales/tr/sidebar.json @@ -115,7 +115,8 @@ "restoreSessionError": "Oturum geri yüklenirken hata oluştu. Lütfen tekrar dene." }, "version": { - "updateAvailable": "Güncelleme mevcut" + "updateAvailable": "Güncelleme mevcut", + "restartRequired": "Güncelleme yüklendi — uygulamak için sunucuyu yeniden başlatın" }, "search": { "modeProjects": "Projeler", diff --git a/src/i18n/locales/zh-CN/sidebar.json b/src/i18n/locales/zh-CN/sidebar.json index a04b5f06..812bbc15 100644 --- a/src/i18n/locales/zh-CN/sidebar.json +++ b/src/i18n/locales/zh-CN/sidebar.json @@ -115,7 +115,8 @@ "restoreSessionError": "恢复会话时出错,请重试。" }, "version": { - "updateAvailable": "有可用更新" + "updateAvailable": "有可用更新", + "restartRequired": "已安装更新 — 请重启服务器以生效" }, "search": { "modeProjects": "项目", diff --git a/src/i18n/locales/zh-TW/sidebar.json b/src/i18n/locales/zh-TW/sidebar.json index ea392402..dab8ad9d 100644 --- a/src/i18n/locales/zh-TW/sidebar.json +++ b/src/i18n/locales/zh-TW/sidebar.json @@ -114,7 +114,8 @@ "restoreSessionError": "還原工作階段時出錯,請重試。" }, "version": { - "updateAvailable": "有可用更新" + "updateAvailable": "有可用更新", + "restartRequired": "已安裝更新 — 請重新啟動伺服器以套用" }, "search": { "modeProjects": "專案",