From 4b4dccd13dc0065054b28ac052bacda852234331 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 21:46:56 +0200 Subject: [PATCH] feat: add skills management features and integrate skills API - Introduced skills catalog management with functionalities to list, get details, preview, and apply skill changes. - Implemented IPC handlers for skills-related actions, enhancing communication between renderer and main processes. - Updated the UI to include a dedicated Skills panel within the extension store, improving user access to skills management. - Added new constants and types for skills API integration, ensuring a structured approach to skills handling. - Enhanced state management to support skills loading, error handling, and detail fetching. --- .../adr-002-skills-in-extensions.md | 137 +++++ package.json | 1 + pnpm-lock.yaml | 3 + src/main/index.ts | 19 + src/main/ipc/handlers.ts | 10 + src/main/ipc/skills.ts | 202 ++++++ src/main/services/extensions/index.ts | 10 + .../extensions/skills/SkillImportService.ts | 83 +++ .../extensions/skills/SkillMetadataParser.ts | 295 +++++++++ .../extensions/skills/SkillReviewService.ts | 73 +++ .../extensions/skills/SkillRootsResolver.ts | 46 ++ .../extensions/skills/SkillScaffoldService.ts | 88 +++ .../extensions/skills/SkillScanner.ts | 117 ++++ .../extensions/skills/SkillValidator.ts | 64 ++ .../extensions/skills/SkillsCatalogService.ts | 86 +++ .../skills/SkillsMutationService.ts | 136 ++++ .../extensions/skills/SkillsWatcherService.ts | 134 ++++ src/preload/constants/ipcChannels.ts | 34 + src/preload/index.ts | 45 ++ .../extensions/ExtensionStoreView.tsx | 73 ++- .../extensions/skills/SkillCodeEditor.tsx | 96 +++ .../extensions/skills/SkillDetailDialog.tsx | 208 +++++++ .../extensions/skills/SkillEditorDialog.tsx | 579 ++++++++++++++++++ .../extensions/skills/SkillImportDialog.tsx | 222 +++++++ .../extensions/skills/SkillReviewDialog.tsx | 135 ++++ .../extensions/skills/SkillsPanel.tsx | 395 ++++++++++++ .../extensions/skills/skillDraftUtils.ts | 147 +++++ src/renderer/hooks/useExtensionsTabState.ts | 18 +- src/renderer/store/slices/extensionsSlice.ts | 183 ++++++ src/shared/types/api.ts | 5 +- src/shared/types/extensions/api.ts | 24 + src/shared/types/extensions/index.ts | 28 +- src/shared/types/extensions/skill.ts | 144 +++++ .../extensions/SkillMetadataParser.test.ts | 62 ++ .../extensions/SkillReviewService.test.ts | 32 + .../extensions/SkillRootsResolver.test.ts | 25 + .../extensions/SkillScaffoldService.test.ts | 39 ++ .../extensions/SkillValidator.test.ts | 60 ++ test/renderer/store/extensionsSlice.test.ts | 22 + 39 files changed, 4062 insertions(+), 18 deletions(-) create mode 100644 docs/extensions/adr-002-skills-in-extensions.md create mode 100644 src/main/ipc/skills.ts create mode 100644 src/main/services/extensions/skills/SkillImportService.ts create mode 100644 src/main/services/extensions/skills/SkillMetadataParser.ts create mode 100644 src/main/services/extensions/skills/SkillReviewService.ts create mode 100644 src/main/services/extensions/skills/SkillRootsResolver.ts create mode 100644 src/main/services/extensions/skills/SkillScaffoldService.ts create mode 100644 src/main/services/extensions/skills/SkillScanner.ts create mode 100644 src/main/services/extensions/skills/SkillValidator.ts create mode 100644 src/main/services/extensions/skills/SkillsCatalogService.ts create mode 100644 src/main/services/extensions/skills/SkillsMutationService.ts create mode 100644 src/main/services/extensions/skills/SkillsWatcherService.ts create mode 100644 src/renderer/components/extensions/skills/SkillCodeEditor.tsx create mode 100644 src/renderer/components/extensions/skills/SkillDetailDialog.tsx create mode 100644 src/renderer/components/extensions/skills/SkillEditorDialog.tsx create mode 100644 src/renderer/components/extensions/skills/SkillImportDialog.tsx create mode 100644 src/renderer/components/extensions/skills/SkillReviewDialog.tsx create mode 100644 src/renderer/components/extensions/skills/SkillsPanel.tsx create mode 100644 src/renderer/components/extensions/skills/skillDraftUtils.ts create mode 100644 src/shared/types/extensions/skill.ts create mode 100644 test/main/services/extensions/SkillMetadataParser.test.ts create mode 100644 test/main/services/extensions/SkillReviewService.test.ts create mode 100644 test/main/services/extensions/SkillRootsResolver.test.ts create mode 100644 test/main/services/extensions/SkillScaffoldService.test.ts create mode 100644 test/main/services/extensions/SkillValidator.test.ts diff --git a/docs/extensions/adr-002-skills-in-extensions.md b/docs/extensions/adr-002-skills-in-extensions.md new file mode 100644 index 00000000..bd3050a3 --- /dev/null +++ b/docs/extensions/adr-002-skills-in-extensions.md @@ -0,0 +1,137 @@ +# ADR-002: Skills In Extensions + +**Date**: 2026-03-11 +**Status**: Accepted + +## Context + +Нужно добавить в `Extensions` first-class раздел `Skills`, не смешивая его ни с `Plugins`, ни с `MCP`, и не строя отдельный remote marketplace. + +Нужны были ответы на три вопроса: + +1. Делаем ли отдельный внешний skills registry/API? +2. Можно ли переиспользовать текущий project editor backend как есть? +3. Какой runtime contract должен быть у локальных skills? + +## Decision Matrix + +### Option A: Remote skills marketplace/API + +Плюсы: +- единый внешний source of truth; +- потенциально install/publish flows позже. + +Минусы: +- не нужен для текущего local-first usage; +- добавляет moderation, trust, publishing, auth и sync surface; +- не соответствует текущему продукту "runs entirely locally". + +Decision: **Rejected for this phase**. + +### Option B: Treat skills as MCP/plugin variants + +Плюсы: +- меньше новых surface areas. + +Минусы: +- семантически неверно: `MCP` = tools/integrations, `Skills` = reusable instructions/workflows; +- разные security and discovery models; +- contracts начинают смешиваться и размывать UX. + +Decision: **Rejected**. + +### Option C: Local-first Skills domain inside Extensions + +Плюсы: +- соответствует реальному source of truth: filesystem skill roots; +- хорошо ложится в existing `Extensions` shell; +- позволяет безопасно поддержать discovery, preview, authoring и review; +- не требует marketplace/runtime model changes. + +Минусы: +- нужен отдельный typed IPC/API слой; +- нужен dedicated security model для non-project roots. + +Decision: **Accepted**. + +## Final Decisions + +### 1. Skills are a separate domain + +- `Plugins` остаются installable plugin packages. +- `MCP` остаётся tooling/integration surface. +- `Skills` становятся local-first reusable workflow/instruction packages. + +### 2. No external skills API in V1/V1.5 + +В этом проходе не делаем: + +- remote registry; +- GitHub one-click install without review; +- publishing pipeline; +- trust badges / moderation / verification. + +### 3. Dedicated internal Skills API + +Renderer работает через отдельный typed contract: + +- list/detail +- preview/apply upsert +- preview/apply import +- delete +- focused watch start/stop + change events + +Это отдельный Skills domain API, а не переиспользование project editor IPC. + +### 4. Reuse renderer components, not editor backend assumptions + +Разрешён reuse: + +- CodeMirror-based editor UI +- Markdown preview/viewers +- Diff viewer +- dialog/button/badge primitives + +Не reuse as-is: + +- current `editor.open(projectPath)` backend +- project-root-only editor security assumptions + +### 5. Source of truth = supported local roots + +Supported roots: + +- project: `.claude/skills`, `.cursor/skills`, `.agents/skills` +- user: `~/.claude/skills`, `~/.cursor/skills`, `~/.agents/skills` + +### 6. Project context is pinned per Extensions tab + +`Extensions` tab stores optional `projectId` and does not silently follow later global selection changes. + +Seed rule: + +- primary: `selectedProjectId` +- fallback: `activeProjectId` + +### 7. Refresh strategy + +- `V1`: mount refresh, manual refresh, mutation refresh +- `V1.5`: focused watcher only while Skills tab is mounted + +No always-on global watcher service for all windows/contexts. + +## Consequences + +Плюсы: +- clearer contracts and UX boundaries; +- safer filesystem mutations; +- predictable per-tab project context; +- easier future extension toward generation/review/publishing. + +Минусы: +- больше отдельных services/files; +- skills lifecycle needs dedicated tests and docs. + +## Implementation Notes + +Implementation was performed in a separate worktree/branch to avoid mixing with the user's dirty main worktree, per plan. diff --git a/package.json b/package.json index d1601c0e..21f228ff 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", "unified": "^11.0.5", + "yaml": "^2.8.2", "yet-another-react-lightbox": "^3.29.1", "zustand": "^4.5.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dd843b7..3a35f350 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -254,6 +254,9 @@ importers: unified: specifier: ^11.0.5 version: 11.0.5 + yaml: + specifier: ^2.8.2 + version: 2.8.2 yet-another-react-lightbox: specifier: ^3.29.1 version: 3.29.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/src/main/index.ts b/src/main/index.ts index 505ee563..62a6c2ae 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -29,6 +29,7 @@ import { SchedulerService } from '@main/services/schedule/SchedulerService'; import { CONTEXT_CHANGED, SCHEDULE_CHANGE, + SKILLS_CHANGED, SSH_STATUS, TEAM_CHANGE, TEAM_TOOL_APPROVAL_EVENT, @@ -84,6 +85,9 @@ import { PluginCatalogService, PluginInstallationStateService, PluginInstallService, + SkillsCatalogService, + SkillsMutationService, + SkillsWatcherService, } from './services/extensions'; import type { FileChangeEvent } from '@main/types'; @@ -339,6 +343,7 @@ let cliInstallerService: CliInstallerService; let ptyTerminalService: PtyTerminalService; let httpServer: HttpServer; let schedulerService: SchedulerService; +let skillsWatcherService: SkillsWatcherService | null = null; // File watcher event cleanup functions let fileChangeCleanup: (() => void) | null = null; @@ -713,6 +718,9 @@ function initializeServices(): void { const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService); const mcpStateService = new McpInstallationStateService(); const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(null); + const skillsCatalogService = new SkillsCatalogService(); + const skillsMutationService = new SkillsMutationService(); + skillsWatcherService = new SkillsWatcherService(); const extensionFacadeService = new ExtensionFacadeService( pluginCatalogService, pluginStateService, @@ -744,6 +752,12 @@ function initializeServices(): void { } }); + skillsWatcherService.setEmitter((event) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(SKILLS_CHANGED, event); + } + }); + teamProvisioningService.setToolApprovalEventEmitter((event) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(TEAM_TOOL_APPROVAL_EVENT, event); @@ -786,6 +800,9 @@ function initializeServices(): void { mcpInstallService, apiKeyService, mcpHealthDiagnosticsService, + skillsCatalogService, + skillsMutationService, + skillsWatcherService, crossTeamService ); @@ -900,6 +917,8 @@ function shutdownServices(): void { void schedulerService.stop(); } + void skillsWatcherService?.stopAll(); + // Kill all PTY processes if (ptyTerminalService) { ptyTerminalService.killAll(); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 5572e348..7a1b6a1c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -66,6 +66,7 @@ import { removeSessionHandlers, } from './sessions'; import { initializeSshHandlers, registerSshHandlers, removeSshHandlers } from './ssh'; +import { initializeSkillsHandlers, registerSkillsHandlers, removeSkillsHandlers } from './skills'; import { initializeSubagentHandlers, registerSubagentHandlers, @@ -109,6 +110,9 @@ import type { McpInstallService } from '../services/extensions/install/McpInstal import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService'; import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService'; +import type { SkillsCatalogService } from '../services/extensions/skills/SkillsCatalogService'; +import type { SkillsMutationService } from '../services/extensions/skills/SkillsMutationService'; +import type { SkillsWatcherService } from '../services/extensions/skills/SkillsWatcherService'; import type { SchedulerService } from '../services/schedule/SchedulerService'; /** @@ -143,6 +147,9 @@ export function initializeIpcHandlers( mcpInstaller?: McpInstallService, apiKeyService?: ApiKeyService, mcpHealthDiagnosticsService?: McpHealthDiagnosticsService, + skillsCatalogService?: SkillsCatalogService, + skillsMutationService?: SkillsMutationService, + skillsWatcherService?: SkillsWatcherService, crossTeamService?: CrossTeamService ): void { // Initialize domain handlers with registry @@ -186,6 +193,7 @@ export function initializeIpcHandlers( apiKeyService, mcpHealthDiagnosticsService ); + initializeSkillsHandlers(skillsCatalogService, skillsMutationService, skillsWatcherService); } if (crossTeamService) { initializeCrossTeamHandlers(crossTeamService); @@ -229,6 +237,7 @@ export function initializeIpcHandlers( } if (extensionFacade) { registerExtensionHandlers(ipcMain); + registerSkillsHandlers(ipcMain); } if (crossTeamService) { registerCrossTeamHandlers(ipcMain); @@ -263,6 +272,7 @@ export function removeIpcHandlers(): void { removeTerminalHandlers(ipcMain); removeHttpServerHandlers(ipcMain); removeExtensionHandlers(ipcMain); + removeSkillsHandlers(ipcMain); removeCrossTeamHandlers(ipcMain); logger.info('All handlers removed'); diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts new file mode 100644 index 00000000..701dc673 --- /dev/null +++ b/src/main/ipc/skills.ts @@ -0,0 +1,202 @@ +import { createLogger } from '@shared/utils/logger'; +import type { + SkillCatalogItem, + SkillDeleteRequest, + SkillDetail, + SkillImportRequest, + SkillReviewPreview, + SkillUpsertRequest, +} from '@shared/types/extensions'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +import type { SkillsCatalogService } from '../services/extensions/skills/SkillsCatalogService'; +import type { SkillsMutationService } from '../services/extensions/skills/SkillsMutationService'; +import type { SkillsWatcherService } from '../services/extensions/skills/SkillsWatcherService'; + +import { + SKILLS_APPLY_IMPORT, + SKILLS_APPLY_UPSERT, + SKILLS_DELETE, + SKILLS_GET_DETAIL, + SKILLS_LIST, + SKILLS_PREVIEW_IMPORT, + SKILLS_PREVIEW_UPSERT, + SKILLS_START_WATCHING, + SKILLS_STOP_WATCHING, +} from '@preload/constants/ipcChannels'; + +const logger = createLogger('IPC:skills'); + +let skillsCatalogService: SkillsCatalogService | null = null; +let skillsMutationService: SkillsMutationService | null = null; +let skillsWatcherService: SkillsWatcherService | null = null; + +export function initializeSkillsHandlers( + skillsCatalog?: SkillsCatalogService, + skillsMutations?: SkillsMutationService, + skillsWatcher?: SkillsWatcherService +): void { + skillsCatalogService = skillsCatalog ?? null; + skillsMutationService = skillsMutations ?? null; + skillsWatcherService = skillsWatcher ?? null; +} + +export function registerSkillsHandlers(ipcMain: IpcMain): void { + ipcMain.handle(SKILLS_LIST, handleSkillsList); + ipcMain.handle(SKILLS_GET_DETAIL, handleSkillsGetDetail); + ipcMain.handle(SKILLS_PREVIEW_UPSERT, handleSkillsPreviewUpsert); + ipcMain.handle(SKILLS_APPLY_UPSERT, handleSkillsApplyUpsert); + ipcMain.handle(SKILLS_PREVIEW_IMPORT, handleSkillsPreviewImport); + ipcMain.handle(SKILLS_APPLY_IMPORT, handleSkillsApplyImport); + ipcMain.handle(SKILLS_DELETE, handleSkillsDelete); + ipcMain.handle(SKILLS_START_WATCHING, handleSkillsStartWatching); + ipcMain.handle(SKILLS_STOP_WATCHING, handleSkillsStopWatching); +} + +export function removeSkillsHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler(SKILLS_LIST); + ipcMain.removeHandler(SKILLS_GET_DETAIL); + ipcMain.removeHandler(SKILLS_PREVIEW_UPSERT); + ipcMain.removeHandler(SKILLS_APPLY_UPSERT); + ipcMain.removeHandler(SKILLS_PREVIEW_IMPORT); + ipcMain.removeHandler(SKILLS_APPLY_IMPORT); + ipcMain.removeHandler(SKILLS_DELETE); + ipcMain.removeHandler(SKILLS_START_WATCHING); + ipcMain.removeHandler(SKILLS_STOP_WATCHING); +} + +interface IpcResult { + success: boolean; + data?: T; + error?: string; +} + +async function wrapHandler(name: string, fn: () => Promise | T): Promise> { + try { + const data = await fn(); + return { success: true, data }; + } catch (error) { + logger.error(`${name} failed`, error); + return { + success: false, + error: error instanceof Error ? error.message : `Unknown error in ${name}`, + }; + } +} + +function getSkillsCatalogService(): SkillsCatalogService { + if (!skillsCatalogService) { + throw new Error('Skills catalog service is not initialized'); + } + return skillsCatalogService; +} + +function getSkillsMutationService(): SkillsMutationService { + if (!skillsMutationService) { + throw new Error('Skills mutation service is not initialized'); + } + return skillsMutationService; +} + +function getSkillsWatcherService(): SkillsWatcherService { + if (!skillsWatcherService) { + throw new Error('Skills watcher service is not initialized'); + } + return skillsWatcherService; +} + +async function handleSkillsList( + _event: IpcMainInvokeEvent, + projectPath?: string +): Promise> { + return wrapHandler('skillsList', () => + getSkillsCatalogService().list(typeof projectPath === 'string' ? projectPath : undefined) + ); +} + +async function handleSkillsGetDetail( + _event: IpcMainInvokeEvent, + skillId?: string, + projectPath?: string +): Promise> { + return wrapHandler('skillsGetDetail', () => { + if (typeof skillId !== 'string' || !skillId) { + throw new Error('skillId is required'); + } + return getSkillsCatalogService().getDetail( + skillId, + typeof projectPath === 'string' ? projectPath : undefined + ); + }); +} + +async function handleSkillsPreviewUpsert( + _event: IpcMainInvokeEvent, + request?: SkillUpsertRequest +): Promise> { + return wrapHandler('skillsPreviewUpsert', () => { + if (!request) throw new Error('request is required'); + return getSkillsMutationService().previewUpsert(request); + }); +} + +async function handleSkillsApplyUpsert( + _event: IpcMainInvokeEvent, + request?: SkillUpsertRequest +): Promise> { + return wrapHandler('skillsApplyUpsert', () => { + if (!request) throw new Error('request is required'); + return getSkillsMutationService().applyUpsert(request); + }); +} + +async function handleSkillsPreviewImport( + _event: IpcMainInvokeEvent, + request?: SkillImportRequest +): Promise> { + return wrapHandler('skillsPreviewImport', () => { + if (!request) throw new Error('request is required'); + return getSkillsMutationService().previewImport(request); + }); +} + +async function handleSkillsApplyImport( + _event: IpcMainInvokeEvent, + request?: SkillImportRequest +): Promise> { + return wrapHandler('skillsApplyImport', () => { + if (!request) throw new Error('request is required'); + return getSkillsMutationService().applyImport(request); + }); +} + +async function handleSkillsDelete( + _event: IpcMainInvokeEvent, + request?: SkillDeleteRequest +): Promise> { + return wrapHandler('skillsDelete', () => { + if (!request) throw new Error('request is required'); + return getSkillsMutationService().deleteSkill(request); + }); +} + +async function handleSkillsStartWatching( + _event: IpcMainInvokeEvent, + projectPath?: string +): Promise> { + return wrapHandler('skillsStartWatching', () => + getSkillsWatcherService().start(typeof projectPath === 'string' ? projectPath : undefined) + ); +} + +async function handleSkillsStopWatching( + _event: IpcMainInvokeEvent, + watchId?: string +): Promise> { + return wrapHandler('skillsStopWatching', () => { + if (typeof watchId !== 'string' || !watchId) { + throw new Error('watchId is required'); + } + return getSkillsWatcherService().stop(watchId); + }); +} diff --git a/src/main/services/extensions/index.ts b/src/main/services/extensions/index.ts index d413f0aa..51d4e6cf 100644 --- a/src/main/services/extensions/index.ts +++ b/src/main/services/extensions/index.ts @@ -14,3 +14,13 @@ export { PluginInstallService } from './install/PluginInstallService'; export { McpInstallService } from './install/McpInstallService'; export { ApiKeyService } from './apikeys/ApiKeyService'; export { GitHubStarsService } from './catalog/GitHubStarsService'; +export { SkillRootsResolver } from './skills/SkillRootsResolver'; +export { SkillScanner } from './skills/SkillScanner'; +export { SkillMetadataParser } from './skills/SkillMetadataParser'; +export { SkillValidator } from './skills/SkillValidator'; +export { SkillsCatalogService } from './skills/SkillsCatalogService'; +export { SkillScaffoldService } from './skills/SkillScaffoldService'; +export { SkillImportService } from './skills/SkillImportService'; +export { SkillReviewService } from './skills/SkillReviewService'; +export { SkillsMutationService } from './skills/SkillsMutationService'; +export { SkillsWatcherService } from './skills/SkillsWatcherService'; diff --git a/src/main/services/extensions/skills/SkillImportService.ts b/src/main/services/extensions/skills/SkillImportService.ts new file mode 100644 index 00000000..3e6495ac --- /dev/null +++ b/src/main/services/extensions/skills/SkillImportService.ts @@ -0,0 +1,83 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { validateOpenPathUserSelected } from '@main/utils/pathValidation'; +import { isBinaryFile } from 'isbinaryfile'; + +import { SkillScanner } from './SkillScanner'; + +export interface ImportedSkillSourceFile { + relativePath: string; + absolutePath: string; + content: string | null; + isBinary: boolean; +} + +export class SkillImportService { + constructor(private readonly scanner = new SkillScanner()) {} + + async validateSourceDir(sourceDir: string): Promise { + const validatedSource = validateOpenPathUserSelected(sourceDir); + if (!validatedSource.valid || !validatedSource.normalizedPath) { + throw new Error(validatedSource.error ?? 'Invalid import source'); + } + + const normalizedSourceDir = validatedSource.normalizedPath; + const sourceStat = await fs.stat(normalizedSourceDir); + if (!sourceStat.isDirectory()) { + throw new Error('Import source must be a directory'); + } + + const detectedSkillFile = await this.scanner.detectSkillFile(normalizedSourceDir); + if (!detectedSkillFile) { + throw new Error('Import source does not contain a valid skill file'); + } + + return normalizedSourceDir; + } + + async readSourceFiles(sourceDir: string): Promise { + const entries = await this.walkDirectory(sourceDir); + return Promise.all( + entries.map(async (absolutePath) => { + const relativePath = path.relative(sourceDir, absolutePath).replace(/\\/g, '/'); + const binary = await isBinaryFile(absolutePath); + return { + relativePath, + absolutePath, + content: binary ? null : await fs.readFile(absolutePath, 'utf8'), + isBinary: binary, + }; + }) + ); + } + + async writeImportedFiles( + targetSkillDir: string, + files: ImportedSkillSourceFile[] + ): Promise { + for (const file of files) { + const destPath = path.join(targetSkillDir, file.relativePath); + await fs.mkdir(path.dirname(destPath), { recursive: true }); + if (file.isBinary) { + await fs.copyFile(file.absolutePath, destPath); + } else { + await fs.writeFile(destPath, file.content ?? '', 'utf8'); + } + } + } + + private async walkDirectory(rootDir: string): Promise { + const dirEntries = await fs.readdir(rootDir, { withFileTypes: true }); + const results = await Promise.all( + dirEntries.map(async (entry) => { + const fullPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + return this.walkDirectory(fullPath); + } + return [fullPath]; + }) + ); + return results.flat().sort((a, b) => a.localeCompare(b)); + } +} diff --git a/src/main/services/extensions/skills/SkillMetadataParser.ts b/src/main/services/extensions/skills/SkillMetadataParser.ts new file mode 100644 index 00000000..5aa82ab4 --- /dev/null +++ b/src/main/services/extensions/skills/SkillMetadataParser.ts @@ -0,0 +1,295 @@ +import * as path from 'node:path'; + +import { createLogger } from '@shared/utils/logger'; +import type { + SkillCatalogItem, + SkillDetail, + SkillDirectoryFlags, + SkillInvocationMode, + SkillValidationIssue, +} from '@shared/types/extensions'; +import YAML from 'yaml'; + +import type { ResolvedSkillRoot } from './SkillRootsResolver'; + +const logger = createLogger('Extensions:SkillParser'); + +const ALLOWED_FRONTMATTER_KEYS = new Set([ + 'name', + 'description', + 'license', + 'compatibility', + 'metadata', + 'allowed-tools', + 'disable-model-invocation', +]); + +const LARGE_SKILL_FILE_BYTES = 50_000; + +interface ParsedFrontmatter { + rawFrontmatter: string | null; + body: string; + data: Record; + issues: SkillValidationIssue[]; +} + +interface BuildSkillInput { + skillDir: string; + folderName: string; + skillFile: string; + rawContent: string; + modifiedAt: number; + flags: SkillDirectoryFlags; + root: ResolvedSkillRoot; +} + +export interface SkillRelatedFiles { + referencesFiles: string[]; + scriptFiles: string[]; + assetFiles: string[]; +} + +export class SkillMetadataParser { + parseCatalogItem(input: BuildSkillInput): SkillCatalogItem { + const { folderName, flags, modifiedAt, rawContent, root, skillDir, skillFile } = input; + const parsed = this.parseFrontmatter(rawContent); + const metadata = this.normalizeMetadata(parsed.data.metadata); + const name = this.readString(parsed.data.name); + const description = this.readString(parsed.data.description); + const issues = [...parsed.issues]; + const fileBaseName = path.basename(skillFile); + + if (!name) { + issues.push({ + code: 'missing-name', + message: 'Skill frontmatter is missing a valid `name` field.', + severity: 'error', + }); + } + + if (!description) { + issues.push({ + code: 'missing-description', + message: 'Skill frontmatter is missing a valid `description` field.', + severity: 'error', + }); + } + + if (name && folderName !== name) { + issues.push({ + code: 'folder-name-mismatch', + message: `Folder name "${folderName}" does not match skill name "${name}".`, + severity: 'error', + }); + } + + if (fileBaseName !== 'SKILL.md') { + issues.push({ + code: 'nonstandard-file-name', + message: `Using "${fileBaseName}" instead of the standard "SKILL.md".`, + severity: 'warning', + }); + } + + const unknownKeys = Object.keys(parsed.data).filter( + (key) => !ALLOWED_FRONTMATTER_KEYS.has(key) + ); + if (unknownKeys.length > 0) { + issues.push({ + code: 'unknown-frontmatter-keys', + message: `Unknown frontmatter keys: ${unknownKeys.join(', ')}.`, + severity: 'warning', + }); + } + + if (Buffer.byteLength(rawContent, 'utf8') > LARGE_SKILL_FILE_BYTES) { + issues.push({ + code: 'large-skill-file', + message: 'SKILL.md is large and may be expensive to load into context.', + severity: 'warning', + }); + } + + if (flags.hasScripts) { + issues.push({ + code: 'has-scripts', + message: + 'This skill includes a scripts directory. Review bundled scripts before trusting it.', + severity: 'warning', + }); + } + + const allowedTools = this.readAllowedTools(parsed.data['allowed-tools']); + if (allowedTools) { + issues.push({ + code: 'allowed-tools-advisory', + message: + '`allowed-tools` is present, but this app does not enforce or verify runtime compatibility.', + severity: 'warning', + }); + } + + const compatibility = this.readString(parsed.data.compatibility); + if ( + compatibility && + /(network|internet|online|env|environment|api key|credential)/iu.test(compatibility) + ) { + issues.push({ + code: 'compatibility-advisory', + message: + '`compatibility` mentions environment or network requirements that this app cannot verify.', + severity: 'warning', + }); + } + + const isValid = !issues.some((issue) => issue.severity === 'error'); + + return { + id: skillDir, + sourceType: 'filesystem', + name: name ?? folderName, + description: description ?? 'Invalid skill metadata', + folderName, + scope: root.scope, + rootKind: root.rootKind, + projectRoot: root.projectRoot, + discoveryRoot: root.rootPath, + skillDir, + skillFile, + license: this.readString(parsed.data.license), + compatibility, + metadata, + allowedTools, + invocationMode: this.readInvocationMode(parsed.data['disable-model-invocation']), + flags, + isValid, + issues, + modifiedAt, + }; + } + + parseDetail( + item: SkillCatalogItem, + rawContent: string, + relatedFiles: SkillRelatedFiles + ): SkillDetail { + const parsed = this.parseFrontmatter(rawContent); + + return { + item, + body: parsed.body, + rawContent, + rawFrontmatter: parsed.rawFrontmatter, + referencesFiles: relatedFiles.referencesFiles, + scriptFiles: relatedFiles.scriptFiles, + assetFiles: relatedFiles.assetFiles, + }; + } + + private parseFrontmatter(rawContent: string): ParsedFrontmatter { + const content = rawContent.replace(/^\uFEFF/, ''); + if (!content.startsWith('---')) { + return { + rawFrontmatter: null, + body: content, + data: {}, + issues: [ + { + code: 'missing-frontmatter', + message: 'SKILL.md is missing YAML frontmatter.', + severity: 'error', + }, + ], + }; + } + + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/u); + if (!match) { + return { + rawFrontmatter: null, + body: content, + data: {}, + issues: [ + { + code: 'invalid-frontmatter', + message: 'Unable to parse YAML frontmatter block.', + severity: 'error', + }, + ], + }; + } + + const rawFrontmatter = match[1]; + const body = match[2] ?? ''; + + try { + const parsed = YAML.parse(rawFrontmatter); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { + rawFrontmatter, + body, + data: {}, + issues: [ + { + code: 'invalid-frontmatter', + message: 'YAML frontmatter must be a mapping/object.', + severity: 'error', + }, + ], + }; + } + + return { + rawFrontmatter, + body, + data: parsed as Record, + issues: [], + }; + } catch (error) { + logger.warn('Failed to parse skill frontmatter', error); + return { + rawFrontmatter, + body, + data: {}, + issues: [ + { + code: 'invalid-frontmatter', + message: 'YAML frontmatter contains invalid syntax.', + severity: 'error', + }, + ], + }; + } + } + + private normalizeMetadata(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [key, String(entryValue)]) + ); + } + + private readString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; + } + + private readAllowedTools(value: unknown): string | undefined { + if (typeof value === 'string') { + return value.trim() || undefined; + } + if (Array.isArray(value)) { + const tools = value.map((entry) => String(entry).trim()).filter(Boolean); + return tools.length > 0 ? tools.join(' ') : undefined; + } + return undefined; + } + + private readInvocationMode(value: unknown): SkillInvocationMode { + return value === true ? 'manual-only' : 'auto'; + } +} diff --git a/src/main/services/extensions/skills/SkillReviewService.ts b/src/main/services/extensions/skills/SkillReviewService.ts new file mode 100644 index 00000000..046456eb --- /dev/null +++ b/src/main/services/extensions/skills/SkillReviewService.ts @@ -0,0 +1,73 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { createLogger } from '@shared/utils/logger'; +import type { SkillDraftFile, SkillReviewFileChange } from '@shared/types/extensions'; + +import type { ImportedSkillSourceFile } from './SkillImportService'; + +const logger = createLogger('Extensions:SkillReview'); + +export class SkillReviewService { + async buildTextChanges( + targetSkillDir: string, + files: SkillDraftFile[] + ): Promise { + return Promise.all( + files.map(async (file) => { + const absolutePath = path.join(targetSkillDir, file.relativePath); + const oldContent = await this.readUtf8IfExists(absolutePath); + return { + relativePath: file.relativePath, + absolutePath, + action: oldContent === null ? 'create' : 'update', + oldContent, + newContent: file.content, + isBinary: false, + } satisfies SkillReviewFileChange; + }) + ); + } + + async buildImportChanges( + targetSkillDir: string, + files: ImportedSkillSourceFile[] + ): Promise { + return Promise.all( + files.map(async (file) => { + const destPath = path.join(targetSkillDir, file.relativePath); + const exists = await this.pathExists(destPath); + const oldContent = file.isBinary ? null : await this.readUtf8IfExists(destPath); + return { + relativePath: file.relativePath, + absolutePath: destPath, + action: exists ? 'update' : 'create', + oldContent, + newContent: file.isBinary ? null : file.content, + isBinary: file.isBinary, + } satisfies SkillReviewFileChange; + }) + ); + } + + private async readUtf8IfExists(filePath: string): Promise { + try { + return await fs.readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.warn(`Failed to read existing file ${filePath}`, error); + return null; + } + } + + private async pathExists(targetPath: string): Promise { + try { + await fs.stat(targetPath); + return true; + } catch { + return false; + } + } +} diff --git a/src/main/services/extensions/skills/SkillRootsResolver.ts b/src/main/services/extensions/skills/SkillRootsResolver.ts new file mode 100644 index 00000000..2f14c61f --- /dev/null +++ b/src/main/services/extensions/skills/SkillRootsResolver.ts @@ -0,0 +1,46 @@ +import * as path from 'node:path'; + +import { getHomeDir } from '@main/utils/pathDecoder'; +import type { SkillRootKind, SkillScope } from '@shared/types/extensions'; + +export interface ResolvedSkillRoot { + scope: SkillScope; + rootKind: SkillRootKind; + projectRoot: string | null; + rootPath: string; +} + +const USER_ROOTS: Array<{ rootKind: SkillRootKind; segments: string[] }> = [ + { rootKind: 'claude', segments: ['.claude', 'skills'] }, + { rootKind: 'cursor', segments: ['.cursor', 'skills'] }, + { rootKind: 'agents', segments: ['.agents', 'skills'] }, +]; + +export class SkillRootsResolver { + resolve(projectPath?: string): ResolvedSkillRoot[] { + const roots: ResolvedSkillRoot[] = []; + const homeDir = getHomeDir(); + + for (const def of USER_ROOTS) { + roots.push({ + scope: 'user', + rootKind: def.rootKind, + projectRoot: null, + rootPath: path.join(homeDir, ...def.segments), + }); + } + + if (projectPath) { + for (const def of USER_ROOTS) { + roots.push({ + scope: 'project', + rootKind: def.rootKind, + projectRoot: projectPath, + rootPath: path.join(projectPath, ...def.segments), + }); + } + } + + return roots; + } +} diff --git a/src/main/services/extensions/skills/SkillScaffoldService.ts b/src/main/services/extensions/skills/SkillScaffoldService.ts new file mode 100644 index 00000000..4dd051f8 --- /dev/null +++ b/src/main/services/extensions/skills/SkillScaffoldService.ts @@ -0,0 +1,88 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation'; +import type { SkillDraftFile, SkillRootKind, SkillScope } from '@shared/types/extensions'; + +import { SkillRootsResolver } from './SkillRootsResolver'; + +export class SkillScaffoldService { + constructor(private readonly rootsResolver = new SkillRootsResolver()) {} + + async resolveUpsertTarget( + scope: SkillScope, + rootKind: SkillRootKind, + projectPath: string | undefined, + folderName: string, + existingSkillId?: string + ): Promise { + const root = this.resolveWritableRoot(scope, rootKind, projectPath); + await fs.mkdir(root.rootPath, { recursive: true }); + + const folderValidation = validateFileName(folderName); + if (!folderValidation.valid) { + throw new Error(folderValidation.error ?? 'Invalid folder name'); + } + + const targetSkillDir = existingSkillId + ? path.resolve(existingSkillId) + : path.join(root.rootPath, folderName); + if (!isPathWithinRoot(targetSkillDir, root.rootPath)) { + throw new Error('Target skill directory is outside the allowed root'); + } + + return targetSkillDir; + } + + normalizeDraftFiles(files: SkillDraftFile[]): SkillDraftFile[] { + return files.map((file) => ({ + ...file, + relativePath: this.normalizeRelativePath(file.relativePath), + })); + } + + async writeTextFiles(targetSkillDir: string, files: SkillDraftFile[]): Promise { + for (const file of files) { + const absolutePath = path.join(targetSkillDir, file.relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, file.content, 'utf8'); + } + } + + private resolveWritableRoot(scope: SkillScope, rootKind: SkillRootKind, projectPath?: string) { + const roots = this.rootsResolver.resolve(projectPath); + const match = roots.find((root) => root.scope === scope && root.rootKind === rootKind); + if (!match) { + throw new Error('Requested skill root is unavailable'); + } + if (scope === 'project' && !projectPath) { + throw new Error('projectPath is required for project-scoped skills'); + } + return match; + } + + private normalizeRelativePath(relativePath: string): string { + if (!relativePath || typeof relativePath !== 'string') { + throw new Error('relativePath is required'); + } + + const normalized = path.normalize(relativePath).replace(/\\/g, '/'); + if (normalized.startsWith('../') || normalized === '..' || path.isAbsolute(normalized)) { + throw new Error(`Invalid relative path: ${relativePath}`); + } + + const parts = normalized.split('/').filter(Boolean); + if (parts.length === 0) { + throw new Error(`Invalid relative path: ${relativePath}`); + } + + for (const part of parts) { + const validation = validateFileName(part); + if (!validation.valid) { + throw new Error(validation.error ?? `Invalid path segment: ${part}`); + } + } + + return parts.join('/'); + } +} diff --git a/src/main/services/extensions/skills/SkillScanner.ts b/src/main/services/extensions/skills/SkillScanner.ts new file mode 100644 index 00000000..bbb1c5bd --- /dev/null +++ b/src/main/services/extensions/skills/SkillScanner.ts @@ -0,0 +1,117 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import type { SkillCatalogItem, SkillDirectoryFlags } from '@shared/types/extensions'; + +import { SkillMetadataParser, type SkillRelatedFiles } from './SkillMetadataParser'; +import type { ResolvedSkillRoot } from './SkillRootsResolver'; + +const SKILL_FILE_CANDIDATES = ['SKILL.md', 'Skill.md', 'skill.md'] as const; + +export class SkillScanner { + constructor(private readonly parser = new SkillMetadataParser()) {} + + async scanRoot(root: ResolvedSkillRoot): Promise { + try { + const rootStat = await fs.stat(root.rootPath); + if (!rootStat.isDirectory()) return []; + } catch { + return []; + } + + const dirEntries = await fs.readdir(root.rootPath, { withFileTypes: true }); + const skillDirs = dirEntries.filter((entry) => entry.isDirectory()); + + const skills = await Promise.all( + skillDirs.map(async (entry) => { + const skillDir = path.join(root.rootPath, entry.name); + const skillFile = await this.detectSkillFile(skillDir); + if (!skillFile) return null; + + const [rawContent, stat, flags] = await Promise.all([ + fs.readFile(skillFile, 'utf8'), + fs.stat(skillFile), + this.readFlags(skillDir), + ]); + + return this.parser.parseCatalogItem({ + skillDir, + folderName: entry.name, + skillFile, + rawContent, + modifiedAt: stat.mtimeMs, + flags, + root, + }); + }) + ); + + return skills.filter((entry): entry is SkillCatalogItem => entry !== null); + } + + async detectSkillFile(skillDir: string): Promise { + for (const candidate of SKILL_FILE_CANDIDATES) { + const filePath = path.join(skillDir, candidate); + try { + const stat = await fs.stat(filePath); + if (stat.isFile()) return filePath; + } catch { + // ignore + } + } + + return null; + } + + async readFlags(skillDir: string): Promise { + const [hasScripts, hasReferences, hasAssets] = await Promise.all([ + this.directoryExists(path.join(skillDir, 'scripts')), + this.directoryExists(path.join(skillDir, 'references')), + this.directoryExists(path.join(skillDir, 'assets')), + ]); + + return { hasScripts, hasReferences, hasAssets }; + } + + async readRelatedFiles(skillDir: string): Promise { + const [referencesFiles, scriptFiles, assetFiles] = await Promise.all([ + this.listRelativeFiles(path.join(skillDir, 'references')), + this.listRelativeFiles(path.join(skillDir, 'scripts')), + this.listRelativeFiles(path.join(skillDir, 'assets')), + ]); + + return { referencesFiles, scriptFiles, assetFiles }; + } + + private async listRelativeFiles(targetDir: string, prefix = ''): Promise { + try { + const stat = await fs.stat(targetDir); + if (!stat.isDirectory()) return []; + } catch { + return []; + } + + const dirEntries = await fs.readdir(targetDir, { withFileTypes: true }); + const files = await Promise.all( + dirEntries.map(async (entry) => { + const relativePath = prefix ? path.join(prefix, entry.name) : entry.name; + const fullPath = path.join(targetDir, entry.name); + if (entry.isDirectory()) { + return this.listRelativeFiles(fullPath, relativePath); + } + return [relativePath]; + }) + ); + + return files.flat().sort((a, b) => a.localeCompare(b)); + } + + private async directoryExists(targetDir: string): Promise { + try { + const stat = await fs.stat(targetDir); + return stat.isDirectory(); + } catch { + return false; + } + } +} diff --git a/src/main/services/extensions/skills/SkillValidator.ts b/src/main/services/extensions/skills/SkillValidator.ts new file mode 100644 index 00000000..a68dfb79 --- /dev/null +++ b/src/main/services/extensions/skills/SkillValidator.ts @@ -0,0 +1,64 @@ +import type { SkillCatalogItem } from '@shared/types/extensions'; + +const ROOT_PRECEDENCE: Record = { + claude: 0, + cursor: 1, + agents: 2, +}; + +export class SkillValidator { + annotateCatalog(items: SkillCatalogItem[]): SkillCatalogItem[] { + const withDuplicates = this.annotateDuplicateNames(items); + return withDuplicates.sort((a, b) => { + if (a.isValid !== b.isValid) return a.isValid ? -1 : 1; + if (a.scope !== b.scope) return a.scope === 'project' ? -1 : 1; + if (a.rootKind !== b.rootKind) + return ROOT_PRECEDENCE[a.rootKind] - ROOT_PRECEDENCE[b.rootKind]; + return a.name.localeCompare(b.name); + }); + } + + private annotateDuplicateNames(items: SkillCatalogItem[]): SkillCatalogItem[] { + const itemsByName = new Map(); + for (const item of items) { + const key = item.name.trim().toLowerCase(); + const bucket = itemsByName.get(key) ?? []; + bucket.push(item); + itemsByName.set(key, bucket); + } + + return items.map((item) => { + const key = item.name.trim().toLowerCase(); + const duplicates = itemsByName.get(key) ?? []; + if (duplicates.length <= 1) { + return item; + } + + if (item.issues.some((issue) => issue.code === 'duplicate-name')) { + return item; + } + + const otherLocations = duplicates + .filter((candidate) => candidate.id !== item.id) + .map((candidate) => `${candidate.skillDir} (${this.formatRootLabel(candidate)})`) + .filter((value, index, values) => values.indexOf(value) === index) + .join('; '); + + return { + ...item, + issues: [ + ...item.issues, + { + code: 'duplicate-name', + message: `Another copy of "${item.name}" exists at: ${otherLocations}. Both entries are shown separately.`, + severity: 'warning', + }, + ], + }; + }); + } + + private formatRootLabel(item: SkillCatalogItem): string { + return item.scope === 'project' ? `project .${item.rootKind}` : `.${item.rootKind}`; + } +} diff --git a/src/main/services/extensions/skills/SkillsCatalogService.ts b/src/main/services/extensions/skills/SkillsCatalogService.ts new file mode 100644 index 00000000..7e46a233 --- /dev/null +++ b/src/main/services/extensions/skills/SkillsCatalogService.ts @@ -0,0 +1,86 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { createLogger } from '@shared/utils/logger'; +import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions'; + +import { SkillMetadataParser } from './SkillMetadataParser'; +import { SkillRootsResolver, type ResolvedSkillRoot } from './SkillRootsResolver'; +import { SkillScanner } from './SkillScanner'; +import { SkillValidator } from './SkillValidator'; + +const logger = createLogger('Extensions:SkillsCatalog'); + +export class SkillsCatalogService { + constructor( + private readonly rootsResolver = new SkillRootsResolver(), + private readonly parser = new SkillMetadataParser(), + private readonly scanner = new SkillScanner(parser), + private readonly validator = new SkillValidator() + ) {} + + async list(projectPath?: string): Promise { + const roots = this.rootsResolver.resolve(projectPath); + const scannedItems = ( + await Promise.all(roots.map((root) => this.readSkillsFromRoot(root))) + ).flat(); + return this.validator.annotateCatalog(scannedItems); + } + + async getDetail(skillId: string, projectPath?: string): Promise { + const roots = this.rootsResolver.resolve(projectPath); + const allowedRoots = new Set(roots.map((root) => path.resolve(root.rootPath))); + const normalizedSkillDir = path.resolve(skillId); + + const owningRoot = roots.find((root) => this.isWithinRoot(normalizedSkillDir, root.rootPath)); + if (!owningRoot || !allowedRoots.has(path.resolve(owningRoot.rootPath))) { + return null; + } + + const folderName = path.basename(normalizedSkillDir); + const skillFile = await this.scanner.detectSkillFile(normalizedSkillDir); + if (!skillFile) return null; + + try { + const [rawContent, stat, flags, relatedFiles] = await Promise.all([ + fs.readFile(skillFile, 'utf8'), + fs.stat(skillFile), + this.scanner.readFlags(normalizedSkillDir), + this.scanner.readRelatedFiles(normalizedSkillDir), + ]); + + const item = this.parser.parseCatalogItem({ + skillDir: normalizedSkillDir, + folderName, + skillFile, + rawContent, + modifiedAt: stat.mtimeMs, + flags, + root: owningRoot, + }); + + return this.parser.parseDetail(item, rawContent, relatedFiles); + } catch (error) { + logger.warn(`Failed to read skill detail for ${skillId}`, error); + return null; + } + } + + private async readSkillsFromRoot(root: ResolvedSkillRoot): Promise { + try { + return await this.scanner.scanRoot(root); + } catch (error) { + logger.warn(`Failed to scan skills root ${root.rootPath}`, error); + return []; + } + } + + private isWithinRoot(targetPath: string, rootPath: string): boolean { + const normalizedTarget = path.resolve(targetPath); + const normalizedRoot = path.resolve(rootPath); + return ( + normalizedTarget === normalizedRoot || + normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`) + ); + } +} diff --git a/src/main/services/extensions/skills/SkillsMutationService.ts b/src/main/services/extensions/skills/SkillsMutationService.ts new file mode 100644 index 00000000..a0d67541 --- /dev/null +++ b/src/main/services/extensions/skills/SkillsMutationService.ts @@ -0,0 +1,136 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import type { + SkillDeleteRequest, + SkillDetail, + SkillImportRequest, + SkillReviewPreview, + SkillUpsertRequest, +} from '@shared/types/extensions'; +import { shell } from 'electron'; + +import { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation'; + +import { SkillImportService } from './SkillImportService'; +import { SkillReviewService } from './SkillReviewService'; +import { SkillScaffoldService } from './SkillScaffoldService'; +import { SkillRootsResolver } from './SkillRootsResolver'; +import { SkillsCatalogService } from './SkillsCatalogService'; + +export class SkillsMutationService { + constructor( + private readonly rootsResolver = new SkillRootsResolver(), + private readonly catalogService = new SkillsCatalogService(), + private readonly scaffoldService = new SkillScaffoldService(rootsResolver), + private readonly importService = new SkillImportService(), + private readonly reviewService = new SkillReviewService() + ) {} + + async previewUpsert(request: SkillUpsertRequest): Promise { + const targetSkillDir = await this.scaffoldService.resolveUpsertTarget( + request.scope, + request.rootKind, + request.projectPath, + request.folderName, + request.existingSkillId + ); + const files = this.scaffoldService.normalizeDraftFiles(request.files); + const changes = await this.reviewService.buildTextChanges(targetSkillDir, files); + return { + targetSkillDir, + changes, + warnings: [], + }; + } + + async applyUpsert(request: SkillUpsertRequest): Promise { + const targetSkillDir = await this.scaffoldService.resolveUpsertTarget( + request.scope, + request.rootKind, + request.projectPath, + request.folderName, + request.existingSkillId + ); + const files = this.scaffoldService.normalizeDraftFiles(request.files); + await this.scaffoldService.writeTextFiles(targetSkillDir, files); + + return this.catalogService.getDetail(targetSkillDir, request.projectPath); + } + + async previewImport(request: SkillImportRequest): Promise { + const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request); + const sourceFiles = await this.importService.readSourceFiles(sourceDir); + const changes = await this.reviewService.buildImportChanges(targetSkillDir, sourceFiles); + const warnings = changes.some((change) => change.isBinary) + ? ['This import includes binary files. Binary files will be copied as-is.'] + : []; + + return { + targetSkillDir, + changes, + warnings, + }; + } + + async applyImport(request: SkillImportRequest): Promise { + const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request); + const sourceFiles = await this.importService.readSourceFiles(sourceDir); + await this.importService.writeImportedFiles(targetSkillDir, sourceFiles); + + return this.catalogService.getDetail(targetSkillDir, request.projectPath); + } + + async deleteSkill(request: SkillDeleteRequest): Promise { + const skillDir = this.resolveExistingSkill(request.skillId, request.projectPath); + await shell.trashItem(skillDir); + } + + private async resolveImportTarget( + request: SkillImportRequest + ): Promise<{ sourceDir: string; targetSkillDir: string }> { + const sourceDir = await this.importService.validateSourceDir(request.sourceDir); + + const root = this.resolveWritableRoot(request.scope, request.rootKind, request.projectPath); + await fs.mkdir(root.rootPath, { recursive: true }); + + const folderName = request.folderName?.trim() || path.basename(sourceDir); + const folderValidation = validateFileName(folderName); + if (!folderValidation.valid) { + throw new Error(folderValidation.error ?? 'Invalid folder name'); + } + + const targetSkillDir = path.join(root.rootPath, folderName); + if (!isPathWithinRoot(targetSkillDir, root.rootPath)) { + throw new Error('Import destination is outside the allowed root'); + } + + return { sourceDir, targetSkillDir }; + } + + private resolveWritableRoot( + scope: SkillUpsertRequest['scope'], + rootKind: SkillUpsertRequest['rootKind'], + projectPath?: string + ) { + const roots = this.rootsResolver.resolve(projectPath); + const match = roots.find((root) => root.scope === scope && root.rootKind === rootKind); + if (!match) { + throw new Error('Requested skill root is unavailable'); + } + if (scope === 'project' && !projectPath) { + throw new Error('projectPath is required for project-scoped skills'); + } + return match; + } + + private resolveExistingSkill(skillId: string, projectPath?: string): string { + const normalizedSkillDir = path.resolve(skillId); + const roots = this.rootsResolver.resolve(projectPath); + const owningRoot = roots.find((root) => isPathWithinRoot(normalizedSkillDir, root.rootPath)); + if (!owningRoot) { + throw new Error('Skill is outside the allowed roots'); + } + return normalizedSkillDir; + } +} diff --git a/src/main/services/extensions/skills/SkillsWatcherService.ts b/src/main/services/extensions/skills/SkillsWatcherService.ts new file mode 100644 index 00000000..08c20dc9 --- /dev/null +++ b/src/main/services/extensions/skills/SkillsWatcherService.ts @@ -0,0 +1,134 @@ +import { createLogger } from '@shared/utils/logger'; +import type { SkillWatcherEvent } from '@shared/types/extensions'; +import { isPathWithinRoot } from '@main/utils/pathValidation'; +import { watch } from 'chokidar'; + +import { SkillRootsResolver } from './SkillRootsResolver'; + +import type { FSWatcher } from 'chokidar'; + +const logger = createLogger('Extensions:SkillsWatcher'); +const WATCHER_DEBOUNCE_MS = 250; + +export class SkillsWatcherService { + private watcher: FSWatcher | null = null; + private subscriptions = new Map(); + private pendingEvents = new Map(); + private flushTimer: ReturnType | null = null; + private emitChange: ((event: SkillWatcherEvent) => void) | null = null; + private nextWatchId = 0; + + constructor(private readonly rootsResolver = new SkillRootsResolver()) {} + + setEmitter(emitChange: (event: SkillWatcherEvent) => void): void { + this.emitChange = emitChange; + } + + async start(projectPath?: string): Promise { + const watchId = `skills-watch-${++this.nextWatchId}`; + this.subscriptions.set(watchId, projectPath ?? null); + await this.rebuildWatcher(); + return watchId; + } + + async stop(watchId: string): Promise { + this.subscriptions.delete(watchId); + await this.rebuildWatcher(); + } + + private async rebuildWatcher(): Promise { + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + this.pendingEvents.clear(); + if (this.watcher) { + await this.watcher.close(); + this.watcher = null; + } + + const roots = [ + ...new Set( + [...this.subscriptions.values()].flatMap((projectPath) => + this.rootsResolver.resolve(projectPath ?? undefined).map((root) => root.rootPath) + ) + ), + ]; + + if (roots.length === 0) { + return; + } + + this.watcher = watch(roots, { + ignoreInitial: true, + ignorePermissionErrors: true, + followSymlinks: false, + depth: 5, + awaitWriteFinish: { + stabilityThreshold: 200, + pollInterval: 100, + }, + }); + + const queue = (type: SkillWatcherEvent['type'], filePath: string): void => { + this.enqueueEventsForPath(type, filePath); + if (this.flushTimer) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + if (this.emitChange) { + for (const event of this.pendingEvents.values()) { + this.emitChange(event); + } + } + this.pendingEvents.clear(); + }, WATCHER_DEBOUNCE_MS); + }; + + this.watcher.on('add', (filePath) => queue('create', filePath)); + this.watcher.on('addDir', (filePath) => queue('create', filePath)); + this.watcher.on('change', (filePath) => queue('change', filePath)); + this.watcher.on('unlink', (filePath) => queue('delete', filePath)); + this.watcher.on('unlinkDir', (filePath) => queue('delete', filePath)); + this.watcher.on('error', (error) => logger.warn('Skills watcher error', error)); + } + + async stopAll(): Promise { + this.subscriptions.clear(); + await this.rebuildWatcher(); + } + + private enqueueEventsForPath(type: SkillWatcherEvent['type'], filePath: string): void { + const matchedProjectPaths = new Set(); + let matchedUserRoot = false; + + for (const projectPath of this.subscriptions.values()) { + const roots = this.rootsResolver.resolve(projectPath ?? undefined); + for (const root of roots) { + if (!isPathWithinRoot(filePath, root.rootPath)) continue; + if (root.scope === 'user') { + matchedUserRoot = true; + } else { + matchedProjectPaths.add(projectPath ?? null); + } + } + } + + if (matchedUserRoot) { + this.pendingEvents.set(`user:${type}`, { + scope: 'user', + projectPath: null, + path: filePath, + type, + }); + } + + for (const projectPath of matchedProjectPaths) { + this.pendingEvents.set(`project:${projectPath ?? 'null'}:${type}`, { + scope: 'project', + projectPath, + path: filePath, + type, + }); + } + } +} diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 5cad249e..ceeff0b7 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -615,6 +615,40 @@ export const MCP_REGISTRY_INSTALL_CUSTOM = 'mcpRegistry:installCustom'; /** Fetch GitHub stars for MCP server repositories */ export const MCP_GITHUB_STARS = 'mcpRegistry:githubStars'; +// ============================================================================= +// Extensions / Skills Channels +// ============================================================================= + +/** List discovered local skills */ +export const SKILLS_LIST = 'skills:list'; + +/** Get full detail for a discovered skill */ +export const SKILLS_GET_DETAIL = 'skills:getDetail'; + +/** Preview create/update changes for a skill */ +export const SKILLS_PREVIEW_UPSERT = 'skills:previewUpsert'; + +/** Apply create/update changes for a skill */ +export const SKILLS_APPLY_UPSERT = 'skills:applyUpsert'; + +/** Preview import changes for a skill folder */ +export const SKILLS_PREVIEW_IMPORT = 'skills:previewImport'; + +/** Apply import for a skill folder */ +export const SKILLS_APPLY_IMPORT = 'skills:applyImport'; + +/** Delete an existing skill */ +export const SKILLS_DELETE = 'skills:delete'; + +/** Start focused watcher for active skill roots */ +export const SKILLS_START_WATCHING = 'skills:startWatching'; + +/** Stop focused watcher for active skill roots */ +export const SKILLS_STOP_WATCHING = 'skills:stopWatching'; + +/** Renderer event for focused skill root changes */ +export const SKILLS_CHANGED = 'skills:changed'; + // ============================================================================= // API Keys Management Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 2b49b8ad..8fdb7bf6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -158,6 +158,16 @@ import { MCP_REGISTRY_INSTALL_CUSTOM, MCP_REGISTRY_UNINSTALL, MCP_GITHUB_STARS, + SKILLS_APPLY_IMPORT, + SKILLS_APPLY_UPSERT, + SKILLS_CHANGED, + SKILLS_DELETE, + SKILLS_GET_DETAIL, + SKILLS_LIST, + SKILLS_PREVIEW_IMPORT, + SKILLS_PREVIEW_UPSERT, + SKILLS_START_WATCHING, + SKILLS_STOP_WATCHING, API_KEYS_LIST, API_KEYS_SAVE, API_KEYS_DELETE, @@ -280,6 +290,13 @@ import type { McpSearchResult, OperationResult, PluginInstallRequest, + SkillCatalogItem, + SkillDeleteRequest, + SkillDetail, + SkillImportRequest, + SkillReviewPreview, + SkillUpsertRequest, + SkillWatcherEvent, } from '@shared/types/extensions'; import type { BinaryPreviewResult, @@ -1397,6 +1414,34 @@ const electronAPI: ElectronAPI = { invokeIpcWithResult>(MCP_GITHUB_STARS, repositoryUrls), }, + // ===== Skills Catalog API (Electron-only) ===== + skills: { + list: (projectPath?: string) => + invokeIpcWithResult(SKILLS_LIST, projectPath), + getDetail: (skillId: string, projectPath?: string) => + invokeIpcWithResult(SKILLS_GET_DETAIL, skillId, projectPath), + previewUpsert: (request: SkillUpsertRequest) => + invokeIpcWithResult(SKILLS_PREVIEW_UPSERT, request), + applyUpsert: (request: SkillUpsertRequest) => + invokeIpcWithResult(SKILLS_APPLY_UPSERT, request), + previewImport: (request: SkillImportRequest) => + invokeIpcWithResult(SKILLS_PREVIEW_IMPORT, request), + applyImport: (request: SkillImportRequest) => + invokeIpcWithResult(SKILLS_APPLY_IMPORT, request), + deleteSkill: (request: SkillDeleteRequest) => invokeIpcWithResult(SKILLS_DELETE, request), + startWatching: (projectPath?: string) => + invokeIpcWithResult(SKILLS_START_WATCHING, projectPath), + stopWatching: (watchId: string) => invokeIpcWithResult(SKILLS_STOP_WATCHING, watchId), + onChanged: (callback: (event: SkillWatcherEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SkillWatcherEvent): void => + callback(data); + ipcRenderer.on(SKILLS_CHANGED, listener); + return (): void => { + ipcRenderer.removeListener(SKILLS_CHANGED, listener); + }; + }, + }, + // ===== API Keys API (Electron-only) ===== apiKeys: { list: () => invokeIpcWithResult(API_KEYS_LIST), diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 36060669..75ed6a2e 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -4,10 +4,11 @@ * Global catalog data comes from Zustand store. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; +import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useStore } from '@renderer/store'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; @@ -17,53 +18,78 @@ import { TooltipProvider, TooltipTrigger, } from '@renderer/components/ui/tooltip'; -import { AlertTriangle, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; +import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; import { ApiKeysPanel } from './apikeys/ApiKeysPanel'; import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog'; import { McpServersPanel } from './mcp/McpServersPanel'; import { PluginsPanel } from './plugins/PluginsPanel'; +import { SkillsPanel } from './skills/SkillsPanel'; export const ExtensionStoreView = (): React.JSX.Element => { + const tabId = useTabIdOptional(); const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog); const fetchApiKeys = useStore((s) => s.fetchApiKeys); + const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); const mcpBrowse = useStore((s) => s.mcpBrowse); const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled); const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading); const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading); + const skillsLoading = useStore((s) => s.skillsLoading); const cliStatus = useStore((s) => s.cliStatus); const cliInstalled = cliStatus?.installed ?? true; // assume installed until checked const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing)); + const projects = useStore((s) => s.projects); + const extensionsTabProjectId = useStore((s) => + tabId + ? (s.paneLayout.panes.flatMap((pane) => pane.tabs).find((tab) => tab.id === tabId) + ?.projectId ?? null) + : null + ); const tabState = useExtensionsTabState(); const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false); + const projectPath = useMemo( + () => projects.find((project) => project.id === extensionsTabProjectId)?.path ?? null, + [extensionsTabProjectId, projects] + ); + const projectLabel = useMemo( + () => projects.find((project) => project.id === extensionsTabProjectId)?.name ?? null, + [extensionsTabProjectId, projects] + ); // Fetch plugin catalog on mount useEffect(() => { - void fetchPluginCatalog(); - }, [fetchPluginCatalog]); + void fetchPluginCatalog(projectPath ?? undefined); + }, [fetchPluginCatalog, projectPath]); // Fetch MCP installed state on mount useEffect(() => { - void mcpFetchInstalled(); - }, [mcpFetchInstalled]); + void mcpFetchInstalled(projectPath ?? undefined); + }, [mcpFetchInstalled, projectPath]); // Fetch API keys on mount useEffect(() => { void fetchApiKeys(); }, [fetchApiKeys]); - // Refresh all data (plugins + MCP browse + installed) - const handleRefresh = useCallback(() => { - void fetchPluginCatalog(undefined, true); - void mcpBrowse(); // re-fetch first page - void mcpFetchInstalled(); - }, [fetchPluginCatalog, mcpBrowse, mcpFetchInstalled]); + // Fetch Skills catalog on mount / project change + useEffect(() => { + void fetchSkillsCatalog(projectPath ?? undefined); + }, [fetchSkillsCatalog, projectPath]); - const isRefreshing = pluginCatalogLoading || mcpBrowseLoading; + // Refresh all data (plugins + MCP browse + installed + skills) + const handleRefresh = useCallback(() => { + void fetchPluginCatalog(projectPath ?? undefined, true); + void mcpBrowse(); // re-fetch first page + void mcpFetchInstalled(projectPath ?? undefined); + void fetchSkillsCatalog(projectPath ?? undefined); + }, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]); + + const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading; // Browser mode guard - if (!api.plugins && !api.mcpRegistry) { + if (!api.plugins && !api.mcpRegistry && !api.skills) { return (
@@ -114,7 +140,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { - tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'api-keys') + tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'skills' | 'api-keys') } >
@@ -127,6 +153,10 @@ export const ExtensionStoreView = (): React.JSX.Element => { MCP Servers + + + Skills + API Keys @@ -176,6 +206,19 @@ export const ExtensionStoreView = (): React.JSX.Element => { + + + + {/* Custom MCP server dialog (lifted to store view level) */} diff --git a/src/renderer/components/extensions/skills/SkillCodeEditor.tsx b/src/renderer/components/extensions/skills/SkillCodeEditor.tsx new file mode 100644 index 00000000..776d8a1f --- /dev/null +++ b/src/renderer/components/extensions/skills/SkillCodeEditor.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef } from 'react'; + +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { + bracketMatching, + foldGutter, + foldKeymap, + indentOnInput, + syntaxHighlighting, +} from '@codemirror/language'; +import { search, searchKeymap } from '@codemirror/search'; +import { EditorState } from '@codemirror/state'; +import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; +import { + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + keymap, + lineNumbers, +} from '@codemirror/view'; +import { baseEditorTheme } from '@renderer/utils/codemirrorTheme'; +import { getSyncLanguageExtension } from '@renderer/utils/codemirrorLanguages'; + +const skillEditorTheme = EditorView.theme({ + '&': { + height: '100%', + }, +}); + +interface SkillCodeEditorProps { + value: string; + onChange: (value: string) => void; +} + +export const SkillCodeEditor = ({ value, onChange }: SkillCodeEditorProps): React.JSX.Element => { + const containerRef = useRef(null); + const viewRef = useRef(null); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + useEffect(() => { + if (!containerRef.current) return; + + const state = EditorState.create({ + doc: value, + extensions: [ + getSyncLanguageExtension('SKILL.md') ?? [], + lineNumbers(), + highlightActiveLineGutter(), + highlightActiveLine(), + history(), + foldGutter(), + indentOnInput(), + bracketMatching(), + search(), + syntaxHighlighting(oneDarkHighlightStyle), + EditorView.lineWrapping, + keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap]), + baseEditorTheme, + skillEditorTheme, + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onChangeRef.current(update.state.doc.toString()); + } + }), + ], + }); + + const view = new EditorView({ + state, + parent: containerRef.current, + }); + + viewRef.current = view; + + return () => { + view.destroy(); + viewRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- create editor once per mount + }, []); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + const currentDoc = view.state.doc.toString(); + if (currentDoc === value) return; + + view.dispatch({ + changes: { from: 0, to: currentDoc.length, insert: value }, + }); + }, [value]); + + return
; +}; diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx new file mode 100644 index 00000000..d5327500 --- /dev/null +++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx @@ -0,0 +1,208 @@ +import { useEffect } from 'react'; + +import { api } from '@renderer/api'; +import { CodeBlockViewer } from '@renderer/components/chat/viewers/CodeBlockViewer'; +import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; +import { useStore } from '@renderer/store'; +import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react'; + +interface SkillDetailDialogProps { + skillId: string | null; + open: boolean; + onClose: () => void; + projectPath: string | null; + onEdit: () => void; + onDeleted: () => void; +} + +export const SkillDetailDialog = ({ + skillId, + open, + onClose, + projectPath, + onEdit, + onDeleted, +}: SkillDetailDialogProps): React.JSX.Element => { + const fetchSkillDetail = useStore((s) => s.fetchSkillDetail); + const deleteSkill = useStore((s) => s.deleteSkill); + const skillsMutationLoading = useStore((s) => s.skillsMutationLoading); + const detail = useStore((s) => (skillId ? s.skillsDetailsById[skillId] : undefined)); + const loading = useStore((s) => + skillId ? (s.skillsDetailLoadingById[skillId] ?? false) : false + ); + + useEffect(() => { + if (!open || !skillId) return; + if (detail === undefined) { + void fetchSkillDetail(skillId, projectPath ?? undefined); + } + }, [detail, fetchSkillDetail, open, projectPath, skillId]); + + const item = detail?.item; + + function formatRootKind(rootKind: 'claude' | 'cursor' | 'agents'): string { + return `.${rootKind}`; + } + + async function handleDelete(): Promise { + if (!item) return; + const confirmed = window.confirm(`Delete skill "${item.name}"? It will be moved to Trash.`); + if (!confirmed) return; + + await deleteSkill({ + skillId: item.id, + projectPath: projectPath ?? undefined, + }); + onDeleted(); + } + + return ( + !next && onClose()}> + + + {item?.name ?? 'Skill details'} + + {item?.description ?? 'Inspect discovered skill metadata and raw instructions.'} + + + + {(loading || (open && skillId && detail === undefined)) && ( +

Loading skill details...

+ )} + + {!loading && detail === null && ( +
+ Unable to load this skill. +
+ )} + + {!loading && detail && item && ( +
+
+ {item.scope} + {formatRootKind(item.rootKind)} + {item.invocationMode} + {item.flags.hasScripts && scripts} + {item.flags.hasReferences && references} + {item.flags.hasAssets && assets} +
+ + {item.issues.length > 0 && ( +
+ {item.issues.map((issue, index) => ( +
+ + {issue.message} +
+ ))} +
+ )} + +
+ + + + +
+ +
+
+ +
+ +
+ + +
+
+

Path

+

{item.skillDir}

+
+ + {detail.scriptFiles.length > 0 && ( +
+

Scripts

+ {detail.scriptFiles.map((file) => ( +

+ {file} +

+ ))} +
+ )} + + {detail.referencesFiles.length > 0 && ( +
+

References

+ {detail.referencesFiles.map((file) => ( +

+ {file} +

+ ))} +
+ )} + + {detail.assetFiles.length > 0 && ( +
+

Assets

+ {detail.assetFiles.map((file) => ( +

+ {file} +

+ ))} +
+ )} +
+
+
+
+ )} +
+
+ ); +}; diff --git a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx new file mode 100644 index 00000000..ecccd3d0 --- /dev/null +++ b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx @@ -0,0 +1,579 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { MarkdownPreviewPane } from '@renderer/components/team/editor/MarkdownPreviewPane'; +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; +import { Input } from '@renderer/components/ui/input'; +import { Label } from '@renderer/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select'; +import { useStore } from '@renderer/store'; +import { FileSearch, RotateCcw, X } from 'lucide-react'; + +import { SkillCodeEditor } from './SkillCodeEditor'; +import { SkillReviewDialog } from './SkillReviewDialog'; +import { + buildSkillDraftFiles, + buildSkillTemplate, + readSkillTemplateInput, + updateSkillTemplateFrontmatter, +} from './skillDraftUtils'; + +import type { + SkillCatalogItem, + SkillDetail, + SkillInvocationMode, + SkillReviewPreview, +} from '@shared/types/extensions'; + +type EditorMode = 'create' | 'edit'; + +interface SkillEditorDialogProps { + open: boolean; + mode: EditorMode; + projectPath: string | null; + projectLabel: string | null; + detail: SkillDetail | null; + onClose: () => void; + onSaved: (skillId: string | null) => void; +} + +function parseInitialName(detail: SkillDetail | null): string { + return detail?.item.name ?? ''; +} + +function parseInitialDescription(detail: SkillDetail | null): string { + return detail?.item.description ?? ''; +} + +export const SkillEditorDialog = ({ + open, + mode, + projectPath, + projectLabel, + detail, + onClose, + onSaved, +}: SkillEditorDialogProps): React.JSX.Element => { + const containerRef = useRef(null); + const rawContentRef = useRef(''); + const previewSkillUpsert = useStore((s) => s.previewSkillUpsert); + const applySkillUpsert = useStore((s) => s.applySkillUpsert); + const skillsMutationLoading = useStore((s) => s.skillsMutationLoading); + const skillsMutationError = useStore((s) => s.skillsMutationError); + + const [scope, setScope] = useState<'user' | 'project'>('user'); + const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude'); + const [folderName, setFolderName] = useState(''); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [license, setLicense] = useState(''); + const [compatibility, setCompatibility] = useState(''); + const [invocationMode, setInvocationMode] = useState('auto'); + const [includeScripts, setIncludeScripts] = useState(false); + const [includeReferences, setIncludeReferences] = useState(false); + const [includeAssets, setIncludeAssets] = useState(false); + const [rawContent, setRawContent] = useState(''); + const [manualRawEdit, setManualRawEdit] = useState(false); + const [splitRatio, setSplitRatio] = useState(0.52); + const [isResizing, setIsResizing] = useState(false); + const [reviewPreview, setReviewPreview] = useState(null); + const [reviewOpen, setReviewOpen] = useState(false); + + const applyMetadataToRawContent = useCallback( + ( + nextValues: Partial<{ + name: string; + description: string; + license: string; + compatibility: string; + invocationMode: SkillInvocationMode; + }> + ) => { + const merged = { + name, + description, + license, + compatibility, + invocationMode, + ...nextValues, + }; + const nextRawContent = + mode === 'create' && !manualRawEdit + ? buildSkillTemplate(merged) + : updateSkillTemplateFrontmatter(rawContentRef.current, merged); + + rawContentRef.current = nextRawContent; + setRawContent(nextRawContent); + }, + [compatibility, description, invocationMode, license, manualRawEdit, mode, name] + ); + + useEffect(() => { + if (!open) return; + + const item = detail?.item; + const nextScope = item?.scope ?? (projectPath ? 'project' : 'user'); + const nextRootKind = item?.rootKind ?? 'claude'; + const nextFolderName = item?.folderName ?? ''; + const nextName = parseInitialName(detail); + const nextDescription = parseInitialDescription(detail); + const nextLicense = item?.license ?? ''; + const nextCompatibility = item?.compatibility ?? ''; + const nextInvocationMode = item?.invocationMode ?? 'auto'; + const nextRawContent = + detail?.rawContent ?? + buildSkillTemplate({ + name: nextName || 'New Skill', + description: nextDescription || 'Describe what this skill helps with.', + license: nextLicense, + compatibility: nextCompatibility, + invocationMode: nextInvocationMode, + }); + const rawInput = readSkillTemplateInput(nextRawContent); + + setScope(nextScope); + setRootKind(nextRootKind); + setFolderName(nextFolderName || nextName || ''); + setName(rawInput.name || nextName || 'New Skill'); + setDescription( + rawInput.description || nextDescription || 'Describe what this skill helps with.' + ); + setLicense(rawInput.license ?? nextLicense); + setCompatibility(rawInput.compatibility ?? nextCompatibility); + setInvocationMode(rawInput.invocationMode ?? nextInvocationMode); + setIncludeScripts(item?.flags.hasScripts ?? false); + setIncludeReferences(item?.flags.hasReferences ?? false); + setIncludeAssets(item?.flags.hasAssets ?? false); + rawContentRef.current = nextRawContent; + setRawContent(nextRawContent); + setManualRawEdit(false); + setReviewPreview(null); + setReviewOpen(false); + }, [detail, mode, open, projectPath]); + + useEffect(() => { + rawContentRef.current = rawContent; + }, [rawContent]); + + const request = useMemo( + () => ({ + scope, + rootKind, + projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, + folderName, + existingSkillId: mode === 'edit' ? detail?.item.id : undefined, + files: buildSkillDraftFiles({ + rawContent, + includeScripts, + includeReferences, + includeAssets, + }), + }), + [ + detail?.item.id, + folderName, + includeAssets, + includeReferences, + includeScripts, + mode, + projectPath, + rawContent, + rootKind, + scope, + ] + ); + const draftFilePaths = useMemo( + () => request.files.map((file) => file.relativePath), + [request.files] + ); + const auxiliaryDraftFilePaths = useMemo( + () => draftFilePaths.filter((filePath) => filePath !== 'SKILL.md'), + [draftFilePaths] + ); + + const canUseProjectScope = Boolean(projectPath); + const title = mode === 'create' ? 'Create skill' : 'Edit skill'; + const descriptionText = + mode === 'create' + ? 'Draft a new local skill, review the filesystem changes, then save it into a supported skill root.' + : 'Update the selected skill and review the resulting file changes before saving.'; + + const handleMouseMove = useCallback((event: MouseEvent): void => { + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const ratio = (event.clientX - rect.left) / rect.width; + setSplitRatio(Math.min(0.75, Math.max(0.25, ratio))); + }, []); + + const handleMouseUp = useCallback((): void => { + setIsResizing(false); + }, []); + + useEffect(() => { + if (!isResizing) return; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [handleMouseMove, handleMouseUp, isResizing]); + + async function handleReview(): Promise { + const preview = await previewSkillUpsert(request); + setReviewPreview(preview); + setReviewOpen(true); + } + + async function handleConfirmSave(): Promise { + const saved = await applySkillUpsert(request); + setReviewOpen(false); + onSaved(saved?.item.id ?? detail?.item.id ?? null); + onClose(); + } + + return ( + <> + !next && onClose()}> + +
+ + {title} + {descriptionText} + + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + setFolderName(event.target.value)} + disabled={mode === 'edit'} + /> +
+ +
+ + +
+
+ +
+
+ + { + const nextValue = event.target.value; + setName(nextValue); + applyMetadataToRawContent({ name: nextValue }); + }} + placeholder="Write concise skill name" + /> +
+
+ + { + const nextValue = event.target.value; + setLicense(nextValue); + applyMetadataToRawContent({ license: nextValue }); + }} + placeholder="MIT" + /> +
+
+ +
+
+ + { + const nextValue = event.target.value; + setDescription(nextValue); + applyMetadataToRawContent({ description: nextValue }); + }} + placeholder="What this skill helps with" + /> +
+
+ + { + const nextValue = event.target.value; + setCompatibility(nextValue); + applyMetadataToRawContent({ compatibility: nextValue }); + }} + placeholder="claude-code, cursor" + /> +
+
+ +
+
+
+

Optional files

+

+ Add starter files that will be included in the review and written together + with `SKILL.md`. +

+
+ {mode === 'edit' && ( + + Root and folder are locked for edits + + )} +
+ +
+ + + + + +
+ + {auxiliaryDraftFilePaths.length > 0 && ( +
+

+ Added files: +

+
+ {auxiliaryDraftFilePaths.map((filePath) => ( + + {filePath} + + ))} +
+
+ )} +
+ + {skillsMutationError && ( +
+ {skillsMutationError} +
+ )} + +
+
+ + +
+ +
+
+ { + setManualRawEdit(true); + rawContentRef.current = value; + setRawContent(value); + + const rawInput = readSkillTemplateInput(value); + if (rawInput.name !== undefined) setName(rawInput.name); + if (rawInput.description !== undefined) + setDescription(rawInput.description); + if (rawInput.license !== undefined) setLicense(rawInput.license); + if (rawInput.compatibility !== undefined) + setCompatibility(rawInput.compatibility); + if (rawInput.invocationMode !== undefined) + setInvocationMode(rawInput.invocationMode); + }} + /> +
+
{ + event.preventDefault(); + setIsResizing(true); + }} + /> +
+ +
+
+
+
+
+ +
+ +

+ Review the file changes first, then confirm save in the next step. +

+ +
+
+ +
+ + setReviewOpen(false)} + onConfirm={() => void handleConfirmSave()} + confirmLabel={mode === 'create' ? 'Create Skill' : 'Save Skill'} + reviewLabel={mode === 'create' ? 'Creating a skill' : 'Saving this skill'} + /> + + ); +}; diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx new file mode 100644 index 00000000..a2197e9e --- /dev/null +++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx @@ -0,0 +1,222 @@ +import { useEffect, useState } from 'react'; + +import { api } from '@renderer/api'; +import { Button } from '@renderer/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; +import { Input } from '@renderer/components/ui/input'; +import { Label } from '@renderer/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select'; +import { useStore } from '@renderer/store'; +import { FileSearch, FolderOpen, X } from 'lucide-react'; + +import { SkillReviewDialog } from './SkillReviewDialog'; + +import type { SkillReviewPreview } from '@shared/types/extensions'; + +interface SkillImportDialogProps { + open: boolean; + projectPath: string | null; + projectLabel: string | null; + onClose: () => void; + onImported: (skillId: string | null) => void; +} + +export const SkillImportDialog = ({ + open, + projectPath, + projectLabel, + onClose, + onImported, +}: SkillImportDialogProps): React.JSX.Element => { + const previewSkillImport = useStore((s) => s.previewSkillImport); + const applySkillImport = useStore((s) => s.applySkillImport); + const skillsMutationLoading = useStore((s) => s.skillsMutationLoading); + const skillsMutationError = useStore((s) => s.skillsMutationError); + + const [sourceDir, setSourceDir] = useState(''); + const [folderName, setFolderName] = useState(''); + const [scope, setScope] = useState<'user' | 'project'>('user'); + const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude'); + const [preview, setPreview] = useState(null); + const [reviewOpen, setReviewOpen] = useState(false); + + useEffect(() => { + if (!open) return; + setSourceDir(''); + setFolderName(''); + setScope(projectPath ? 'project' : 'user'); + setRootKind('claude'); + setPreview(null); + setReviewOpen(false); + }, [open, projectPath]); + + async function handleChooseFolder(): Promise { + const selected = await api.config.selectFolders(); + const first = selected[0]; + if (!first) return; + setSourceDir(first); + if (!folderName) { + const segments = first.split(/[\\/]/u).filter(Boolean); + setFolderName(segments.at(-1) ?? ''); + } + } + + async function handleReview(): Promise { + const nextPreview = await previewSkillImport({ + sourceDir, + folderName: folderName || undefined, + scope, + rootKind, + projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, + }); + setPreview(nextPreview); + setReviewOpen(true); + } + + async function handleConfirmImport(): Promise { + const detail = await applySkillImport({ + sourceDir, + folderName: folderName || undefined, + scope, + rootKind, + projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, + }); + setReviewOpen(false); + onImported(detail?.item.id ?? null); + onClose(); + } + + return ( + <> + !next && onClose()}> + +
+ + Import skill + + Pick an existing skill folder, review the copy plan, then import it into a supported + root. + + + +
+
+
+ +
+ setSourceDir(event.target.value)} + /> + +
+
+ +
+ + setFolderName(event.target.value)} + placeholder="Defaults to source folder name" + /> +
+ +
+
+ + +
+ +
+ + +
+
+ + {skillsMutationError && ( +
+ {skillsMutationError} +
+ )} +
+
+ +
+ +

+ Review the copied files first, then confirm the import in the next step. +

+ +
+
+
+
+ + setReviewOpen(false)} + onConfirm={() => void handleConfirmImport()} + confirmLabel="Import Skill" + reviewLabel="Importing this skill" + /> + + ); +}; diff --git a/src/renderer/components/extensions/skills/SkillReviewDialog.tsx b/src/renderer/components/extensions/skills/SkillReviewDialog.tsx new file mode 100644 index 00000000..aca94234 --- /dev/null +++ b/src/renderer/components/extensions/skills/SkillReviewDialog.tsx @@ -0,0 +1,135 @@ +import { DiffViewer } from '@renderer/components/chat/viewers/DiffViewer'; +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; +import { CheckCircle2, ChevronLeft, Save } from 'lucide-react'; + +import type { SkillReviewPreview } from '@shared/types/extensions'; + +interface SkillReviewDialogProps { + open: boolean; + preview: SkillReviewPreview | null; + loading?: boolean; + onClose: () => void; + onConfirm: () => void; + confirmLabel: string; + reviewLabel: string; +} + +export const SkillReviewDialog = ({ + open, + preview, + loading = false, + onClose, + onConfirm, + confirmLabel, + reviewLabel, +}: SkillReviewDialogProps): React.JSX.Element => { + const hasChanges = Boolean(preview && preview.changes.length > 0); + + return ( + !next && onClose()}> + +
+ + Review skill changes + + {reviewLabel} previews the filesystem changes first. Nothing is written until you + confirm below. + + + +
+ {!preview &&

No preview available.

} + + {preview && ( +
+
+
+ {preview.changes.length} file changes +
+
+ {preview.targetSkillDir} +
+

+ Review the diff below, then use{' '} + {confirmLabel} to apply these + changes. +

+
+ + {preview.warnings.length > 0 && ( +
+ {preview.warnings.map((warning, index) => ( +

{warning}

+ ))} +
+ )} + + {!hasChanges && ( +
+ No file changes detected yet. +
+ )} + +
+ {preview.changes.map((change) => ( +
+
+ + {change.action} + + {change.relativePath} + {change.isBinary && binary} +
+ + {change.isBinary ? ( +
+ Binary file preview is not shown. The file will be copied as-is. +
+ ) : ( +
+ +
+ )} +
+ ))} +
+
+ )} +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx new file mode 100644 index 00000000..4e07dfec --- /dev/null +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -0,0 +1,395 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; +import { useStore } from '@renderer/store'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { + AlertTriangle, + ArrowUpAZ, + ArrowUpDown, + BookOpen, + Check, + CheckCircle2, + Clock3, + Download, + Plus, + Search, +} from 'lucide-react'; + +import { SearchInput } from '../common/SearchInput'; + +import { SkillDetailDialog } from './SkillDetailDialog'; +import { SkillEditorDialog } from './SkillEditorDialog'; +import { SkillImportDialog } from './SkillImportDialog'; + +import type { SkillCatalogItem } from '@shared/types/extensions'; +import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState'; + +const SUCCESS_BANNER_MS = 2500; + +interface SkillsPanelProps { + projectPath: string | null; + projectLabel: string | null; + skillsSearchQuery: string; + setSkillsSearchQuery: (value: string) => void; + skillsSort: SkillsSortState; + setSkillsSort: (value: SkillsSortState) => void; + selectedSkillId: string | null; + setSelectedSkillId: (id: string | null) => void; +} + +function sortSkills(skills: SkillCatalogItem[], sort: SkillsSortState): SkillCatalogItem[] { + const next = [...skills]; + next.sort((a, b) => { + if (sort === 'recent-desc') { + return b.modifiedAt - a.modifiedAt || a.name.localeCompare(b.name); + } + return a.name.localeCompare(b.name) || b.modifiedAt - a.modifiedAt; + }); + return next; +} + +function formatRootKind(rootKind: SkillCatalogItem['rootKind']): string { + return `.${rootKind}`; +} + +export const SkillsPanel = ({ + projectPath, + projectLabel, + skillsSearchQuery, + setSkillsSearchQuery, + skillsSort, + setSkillsSort, + selectedSkillId, + setSelectedSkillId, +}: SkillsPanelProps): React.JSX.Element => { + const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); + const fetchSkillDetail = useStore((s) => s.fetchSkillDetail); + const skillsLoading = useStore((s) => s.skillsLoading); + const skillsError = useStore((s) => s.skillsError); + const detailById = useStore((s) => s.skillsDetailsById); + const userSkills = useStore((s) => s.skillsUserCatalog); + const projectSkills = useStore((s) => + projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : [] + ); + const [createOpen, setCreateOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [sortMenuOpen, setSortMenuOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + const selectedSkillIdRef = useRef(selectedSkillId); + selectedSkillIdRef.current = selectedSkillId; + + const mergedSkills = useMemo( + () => [...projectSkills, ...userSkills], + [projectSkills, userSkills] + ); + const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null; + + useEffect(() => { + if (!selectedSkillId) return; + if (mergedSkills.some((skill) => skill.id === selectedSkillId)) return; + setSelectedSkillId(null); + }, [mergedSkills, selectedSkillId, setSelectedSkillId]); + + useEffect(() => { + if (!successMessage) return; + const timeoutId = window.setTimeout(() => setSuccessMessage(null), SUCCESS_BANNER_MS); + return () => window.clearTimeout(timeoutId); + }, [successMessage]); + + useEffect(() => { + const skillsApi = api.skills; + if (!skillsApi) return; + + let watchId: string | null = null; + let disposed = false; + void skillsApi.startWatching(projectPath ?? undefined).then((id) => { + if (disposed) { + void skillsApi.stopWatching(id); + return; + } + watchId = id; + }); + const changeCleanup = skillsApi.onChanged((event) => { + const shouldRefresh = + event.scope === 'user' || + (event.scope === 'project' && event.projectPath === (projectPath ?? null)); + if (!shouldRefresh) return; + + void fetchSkillsCatalog(projectPath ?? undefined); + if (selectedSkillIdRef.current) { + void fetchSkillDetail(selectedSkillIdRef.current, projectPath ?? undefined); + } + }); + + return () => { + disposed = true; + changeCleanup(); + if (watchId) { + void skillsApi.stopWatching(watchId); + } + }; + }, [fetchSkillDetail, fetchSkillsCatalog, projectPath]); + + const visibleSkills = useMemo(() => { + const q = skillsSearchQuery.trim().toLowerCase(); + const filtered = q + ? mergedSkills.filter( + (skill) => + skill.name.toLowerCase().includes(q) || + skill.description.toLowerCase().includes(q) || + skill.folderName.toLowerCase().includes(q) + ) + : mergedSkills; + return sortSkills(filtered, skillsSort); + }, [mergedSkills, skillsSearchQuery, skillsSort]); + + return ( +
+
+
+
+
+ +

Local skills catalog

+
+

+ {projectPath + ? `Project skills for ${projectLabel ?? projectPath} plus your user-level skills.` + : 'User-level skills only. Select a project to include project-scoped skill roots.'} +

+
+ +
+
+
+ +
+
+ + + + + + + + + + Sort skills + + + + + + +
+
+ +
+ + {mergedSkills.length} discovered + + + {projectSkills.length} project + + + {userSkills.length} user + +
+
+
+
+ + {skillsError && ( +
+ {skillsError} +
+ )} + + {successMessage && ( +
+ + {successMessage} +
+ )} + + {skillsLoading && visibleSkills.length === 0 && ( +
+ Loading skills... +
+ )} + + {!skillsLoading && !skillsError && visibleSkills.length === 0 && ( +
+
+ +
+

+ {skillsSearchQuery ? 'No skills match your search' : 'No local skills found'} +

+

+ {skillsSearchQuery + ? 'Try a different search term.' + : 'Skills are discovered from .claude/skills, .cursor/skills, and .agents/skills roots.'} +

+
+ )} + + {visibleSkills.length > 0 && ( +
+ {visibleSkills.map((skill) => ( + + ))} +
+ )} + + setSelectedSkillId(null)} + projectPath={projectPath} + onEdit={() => setEditOpen(true)} + onDeleted={() => setSelectedSkillId(null)} + /> + + setCreateOpen(false)} + onSaved={(skillId) => { + setCreateOpen(false); + setSuccessMessage('Skill created successfully.'); + setSelectedSkillId(skillId); + }} + /> + + setEditOpen(false)} + onSaved={(skillId) => { + setEditOpen(false); + setSuccessMessage('Skill saved successfully.'); + setSelectedSkillId(skillId); + }} + /> + + setImportOpen(false)} + onImported={(skillId) => { + setImportOpen(false); + setSuccessMessage('Skill imported successfully.'); + setSelectedSkillId(skillId); + }} + /> +
+ ); +}; diff --git a/src/renderer/components/extensions/skills/skillDraftUtils.ts b/src/renderer/components/extensions/skills/skillDraftUtils.ts new file mode 100644 index 00000000..ed35f332 --- /dev/null +++ b/src/renderer/components/extensions/skills/skillDraftUtils.ts @@ -0,0 +1,147 @@ +import YAML from 'yaml'; + +import type { SkillDraftFile, SkillDraftTemplateInput } from '@shared/types/extensions'; + +const SKILL_FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/u; + +export interface SkillDraftOptions { + rawContent: string; + includeScripts: boolean; + includeReferences: boolean; + includeAssets: boolean; +} + +function trimTrailingWhitespace(value: string): string { + return value + .split('\n') + .map((line) => line.replace(/\s+$/u, '')) + .join('\n') + .trim(); +} + +export function buildSkillTemplate(input: SkillDraftTemplateInput): string { + const lines = [ + '---', + `name: ${input.name || 'New Skill'}`, + `description: ${input.description || 'Describe what this skill helps with.'}`, + ...(input.license ? [`license: ${input.license}`] : []), + ...(input.compatibility ? [`compatibility: ${input.compatibility}`] : []), + ...(input.invocationMode === 'manual-only' ? ['disable-model-invocation: true'] : []), + '---', + '', + `# ${input.name || 'New Skill'}`, + '', + input.description || 'Describe what this skill helps with.', + '', + '## When to use', + '- Add the conditions where this skill should be selected.', + '', + '## Steps', + '1. Describe the first step.', + '2. Describe the second step.', + '', + '## Notes', + '- Add caveats, review rules, or references.', + ]; + + return trimTrailingWhitespace(lines.join('\n')); +} + +export function readSkillTemplateInput(rawContent: string): Partial { + const content = rawContent.replace(/^\uFEFF/u, ''); + const match = content.match(SKILL_FRONTMATTER_PATTERN); + if (!match) { + return {}; + } + + try { + const parsed = YAML.parse(match[1]); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + const data = parsed as Record; + return { + name: typeof data.name === 'string' ? data.name : undefined, + description: typeof data.description === 'string' ? data.description : undefined, + license: typeof data.license === 'string' ? data.license : undefined, + compatibility: typeof data.compatibility === 'string' ? data.compatibility : undefined, + invocationMode: data['disable-model-invocation'] === true ? 'manual-only' : 'auto', + }; + } catch { + return {}; + } +} + +export function updateSkillTemplateFrontmatter( + rawContent: string, + input: SkillDraftTemplateInput +): string { + const content = rawContent.replace(/^\uFEFF/u, ''); + const match = content.match(SKILL_FRONTMATTER_PATTERN); + const body = match ? (match[2] ?? '') : content; + + let data: Record = {}; + if (match) { + try { + const parsed = YAML.parse(match[1]); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + data = { ...(parsed as Record) }; + } + } catch { + data = {}; + } + } + + data.name = input.name || 'New Skill'; + data.description = input.description || 'Describe what this skill helps with.'; + + if (input.license) { + data.license = input.license; + } else { + delete data.license; + } + + if (input.compatibility) { + data.compatibility = input.compatibility; + } else { + delete data.compatibility; + } + + if (input.invocationMode === 'manual-only') { + data['disable-model-invocation'] = true; + } else { + delete data['disable-model-invocation']; + } + + const frontmatter = YAML.stringify(data).trimEnd(); + const normalizedBody = body.replace(/^\n+/u, ''); + return `---\n${frontmatter}\n---${normalizedBody ? `\n\n${normalizedBody}` : '\n'}`; +} + +export function buildSkillDraftFiles(options: SkillDraftOptions): SkillDraftFile[] { + const files: SkillDraftFile[] = [{ relativePath: 'SKILL.md', content: options.rawContent }]; + + if (options.includeReferences) { + files.push({ + relativePath: 'references/README.md', + content: '# References\n\nAdd supporting docs, examples, or links for this skill.\n', + }); + } + + if (options.includeScripts) { + files.push({ + relativePath: 'scripts/README.md', + content: '# Scripts\n\nAdd optional helper scripts used by this skill.\n', + }); + } + + if (options.includeAssets) { + files.push({ + relativePath: 'assets/README.md', + content: '# Assets\n\nStore screenshots or other bundled assets here.\n', + }); + } + + return files; +} diff --git a/src/renderer/hooks/useExtensionsTabState.ts b/src/renderer/hooks/useExtensionsTabState.ts index 77f4263b..cffc4e9a 100644 --- a/src/renderer/hooks/useExtensionsTabState.ts +++ b/src/renderer/hooks/useExtensionsTabState.ts @@ -16,7 +16,8 @@ import type { PluginSortField, } from '@shared/types/extensions'; -export type ExtensionsSubTab = 'plugins' | 'mcp-servers' | 'api-keys'; +export type ExtensionsSubTab = 'plugins' | 'mcp-servers' | 'skills' | 'api-keys'; +export type SkillsSortState = 'name-asc' | 'recent-desc'; interface PluginSortState { field: PluginSortField; @@ -49,6 +50,12 @@ export function useExtensionsTabState() { const [mcpSearchWarnings, setMcpSearchWarnings] = useState([]); const [selectedMcpServerId, setSelectedMcpServerId] = useState(null); + // ── Skills browse ── + const [skillsSearchQuery, setSkillsSearchQuery] = useState(''); + const [skillsInstalledOnly] = useState(false); + const [skillsSort, setSkillsSort] = useState('name-asc'); + const [selectedSkillId, setSelectedSkillId] = useState(null); + // ── Debounced MCP search ── const searchTimerRef = useRef | null>(null); @@ -163,5 +170,14 @@ export function useExtensionsTabState() { mcpSearchWarnings, selectedMcpServerId, setSelectedMcpServerId, + + // Skills + skillsSearchQuery, + setSkillsSearchQuery, + skillsInstalledOnly, + skillsSort, + setSkillsSort, + selectedSkillId, + setSelectedSkillId, }; } diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 58df5d40..3966cbbb 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -19,6 +19,12 @@ import type { McpInstallRequest, McpServerDiagnostic, PluginInstallRequest, + SkillCatalogItem, + SkillDeleteRequest, + SkillDetail, + SkillImportRequest, + SkillReviewPreview, + SkillUpsertRequest, } from '@shared/types/extensions'; import type { StateCreator } from 'zustand'; @@ -59,6 +65,16 @@ export interface ExtensionsSlice { apiKeySaving: boolean; apiKeyStorageStatus: ApiKeyStorageStatus | null; + // ── Skills catalog cache ── + skillsUserCatalog: SkillCatalogItem[]; + skillsProjectCatalogByProjectPath: Record; + skillsLoading: boolean; + skillsError: string | null; + skillsDetailsById: Record; + skillsDetailLoadingById: Record; + skillsMutationLoading: boolean; + skillsMutationError: string | null; + // ── GitHub Stars (supplementary) ── mcpGitHubStars: Record; @@ -68,6 +84,13 @@ export interface ExtensionsSlice { mcpBrowse: (cursor?: string) => Promise; mcpFetchInstalled: (projectPath?: string) => Promise; runMcpDiagnostics: () => Promise; + fetchSkillsCatalog: (projectPath?: string) => Promise; + fetchSkillDetail: (skillId: string, projectPath?: string) => Promise; + previewSkillUpsert: (request: SkillUpsertRequest) => Promise; + applySkillUpsert: (request: SkillUpsertRequest) => Promise; + previewSkillImport: (request: SkillImportRequest) => Promise; + applySkillImport: (request: SkillImportRequest) => Promise; + deleteSkill: (request: SkillDeleteRequest) => Promise; // ── Mutation actions ── installPlugin: (request: PluginInstallRequest) => Promise; @@ -137,6 +160,15 @@ export const createExtensionsSlice: StateCreator { + if (!api.skills) return; + + set({ skillsLoading: true, skillsError: null }); + try { + const skills = await api.skills.list(projectPath); + set((prev) => ({ + skillsLoading: false, + skillsError: null, + skillsUserCatalog: skills.filter((skill) => skill.scope === 'user'), + skillsProjectCatalogByProjectPath: projectPath + ? { + ...prev.skillsProjectCatalogByProjectPath, + [projectPath]: skills.filter((skill) => skill.scope === 'project'), + } + : prev.skillsProjectCatalogByProjectPath, + })); + } catch (err) { + set({ + skillsLoading: false, + skillsError: err instanceof Error ? err.message : 'Failed to load skills', + }); + } + }, + + fetchSkillDetail: async (skillId: string, projectPath?: string) => { + if (!api.skills) return; + + set((prev) => ({ + skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: true }, + })); + + try { + const detail = await api.skills.getDetail(skillId, projectPath); + set((prev) => ({ + skillsDetailsById: { ...prev.skillsDetailsById, [skillId]: detail }, + skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: false }, + })); + } catch { + set((prev) => ({ + skillsDetailsById: { ...prev.skillsDetailsById, [skillId]: null }, + skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: false }, + })); + } + }, + + previewSkillUpsert: async (request: SkillUpsertRequest) => { + if (!api.skills) { + throw new Error('Skills API is not available'); + } + + set({ skillsMutationLoading: true, skillsMutationError: null }); + try { + const preview = await api.skills.previewUpsert(request); + set({ skillsMutationLoading: false }); + return preview; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to review skill changes'; + set({ skillsMutationLoading: false, skillsMutationError: message }); + throw err; + } + }, + + applySkillUpsert: async (request: SkillUpsertRequest) => { + if (!api.skills) { + throw new Error('Skills API is not available'); + } + + set({ skillsMutationLoading: true, skillsMutationError: null }); + try { + const detail = await api.skills.applyUpsert(request); + await get().fetchSkillsCatalog(request.projectPath); + set((prev) => ({ + skillsMutationLoading: false, + skillsDetailsById: detail?.item.id + ? { ...prev.skillsDetailsById, [detail.item.id]: detail } + : prev.skillsDetailsById, + })); + return detail; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to save skill'; + set({ skillsMutationLoading: false, skillsMutationError: message }); + throw err; + } + }, + + previewSkillImport: async (request: SkillImportRequest) => { + if (!api.skills) { + throw new Error('Skills API is not available'); + } + + set({ skillsMutationLoading: true, skillsMutationError: null }); + try { + const preview = await api.skills.previewImport(request); + set({ skillsMutationLoading: false }); + return preview; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to review import changes'; + set({ skillsMutationLoading: false, skillsMutationError: message }); + throw err; + } + }, + + applySkillImport: async (request: SkillImportRequest) => { + if (!api.skills) { + throw new Error('Skills API is not available'); + } + + set({ skillsMutationLoading: true, skillsMutationError: null }); + try { + const detail = await api.skills.applyImport(request); + await get().fetchSkillsCatalog(request.projectPath); + set((prev) => ({ + skillsMutationLoading: false, + skillsDetailsById: detail?.item.id + ? { ...prev.skillsDetailsById, [detail.item.id]: detail } + : prev.skillsDetailsById, + })); + return detail; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to import skill'; + set({ skillsMutationLoading: false, skillsMutationError: message }); + throw err; + } + }, + + deleteSkill: async (request: SkillDeleteRequest) => { + if (!api.skills) { + throw new Error('Skills API is not available'); + } + + set({ skillsMutationLoading: true, skillsMutationError: null }); + try { + await api.skills.deleteSkill(request); + await get().fetchSkillsCatalog(request.projectPath); + set((prev) => { + const nextDetails = { ...prev.skillsDetailsById }; + delete nextDetails[request.skillId]; + return { + skillsMutationLoading: false, + skillsDetailsById: nextDetails, + }; + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete skill'; + set({ skillsMutationLoading: false, skillsMutationError: message }); + throw err; + } + }, + // ── Plugin install ── installPlugin: async (request: PluginInstallRequest) => { if (!api.plugins) return; @@ -595,6 +777,7 @@ export const createExtensionsSlice: StateCreator Promise>; } +// ── Skills API ───────────────────────────────────────────────────────────── + +export interface SkillsCatalogAPI { + list: (projectPath?: string) => Promise; + getDetail: (skillId: string, projectPath?: string) => Promise; + previewUpsert: (request: SkillUpsertRequest) => Promise; + applyUpsert: (request: SkillUpsertRequest) => Promise; + previewImport: (request: SkillImportRequest) => Promise; + applyImport: (request: SkillImportRequest) => Promise; + deleteSkill: (request: SkillDeleteRequest) => Promise; + startWatching: (projectPath?: string) => Promise; + stopWatching: (watchId: string) => Promise; + onChanged: (callback: (event: SkillWatcherEvent) => void) => () => void; +} + // ── API Keys API ────────────────────────────────────────────────────────── export interface ApiKeysAPI { diff --git a/src/shared/types/extensions/index.ts b/src/shared/types/extensions/index.ts index 689423ee..92d394ca 100644 --- a/src/shared/types/extensions/index.ts +++ b/src/shared/types/extensions/index.ts @@ -33,6 +33,32 @@ export type { McpToolDef, } from './mcp'; +export type { + CreateSkillRequest, + DeleteSkillRequest, + SkillCatalogItem, + SkillDeleteRequest, + SkillDraft, + SkillDraftFile, + SkillDraftTemplateInput, + SkillDetail, + SkillDirectoryFlags, + SkillImportRequest, + SkillInvocationMode, + SkillIssueSeverity, + SkillRootKind, + SkillReviewAction, + SkillReviewFileChange, + SkillReviewPreview, + SkillSaveResult, + SkillScope, + SkillSourceType, + UpdateSkillRequest, + SkillUpsertRequest, + SkillValidationIssue, + SkillWatcherEvent, +} from './skill'; + export type { ApiKeyEntry, ApiKeyLookupResult, @@ -40,4 +66,4 @@ export type { ApiKeyStorageStatus, } from './apikey'; -export type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI } from './api'; +export type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI, SkillsCatalogAPI } from './api'; diff --git a/src/shared/types/extensions/skill.ts b/src/shared/types/extensions/skill.ts new file mode 100644 index 00000000..20d2d98c --- /dev/null +++ b/src/shared/types/extensions/skill.ts @@ -0,0 +1,144 @@ +/** + * Skill domain types — local filesystem-backed skill catalog metadata and details. + */ + +export type SkillScope = 'user' | 'project'; + +export type SkillRootKind = 'claude' | 'cursor' | 'agents'; + +export type SkillSourceType = 'filesystem'; + +export type SkillInvocationMode = 'auto' | 'manual-only'; + +export type SkillIssueSeverity = 'warning' | 'error'; + +export interface SkillDirectoryFlags { + hasScripts: boolean; + hasReferences: boolean; + hasAssets: boolean; +} + +export interface SkillValidationIssue { + code: + | 'missing-frontmatter' + | 'invalid-frontmatter' + | 'missing-name' + | 'missing-description' + | 'folder-name-mismatch' + | 'nonstandard-file-name' + | 'unknown-frontmatter-keys' + | 'large-skill-file' + | 'has-scripts' + | 'allowed-tools-advisory' + | 'compatibility-advisory' + | 'duplicate-name'; + message: string; + severity: SkillIssueSeverity; +} + +export interface SkillCatalogItem { + id: string; + sourceType: SkillSourceType; + name: string; + description: string; + folderName: string; + scope: SkillScope; + rootKind: SkillRootKind; + projectRoot: string | null; + discoveryRoot: string; + skillDir: string; + skillFile: string; + license?: string; + compatibility?: string; + metadata: Record; + allowedTools?: string; + invocationMode: SkillInvocationMode; + flags: SkillDirectoryFlags; + isValid: boolean; + issues: SkillValidationIssue[]; + modifiedAt: number; +} + +export interface SkillDetail { + item: SkillCatalogItem; + body: string; + rawContent: string; + rawFrontmatter: string | null; + referencesFiles: string[]; + scriptFiles: string[]; + assetFiles: string[]; +} + +export interface SkillDraftFile { + relativePath: string; + content: string; +} + +export interface SkillDraft { + rawContent: string; + files: SkillDraftFile[]; +} + +export interface SkillDraftTemplateInput { + name: string; + description: string; + invocationMode: SkillInvocationMode; + license: string; + compatibility: string; +} + +export type SkillReviewAction = 'create' | 'update'; + +export interface SkillReviewFileChange { + relativePath: string; + absolutePath: string; + action: SkillReviewAction; + oldContent: string | null; + newContent: string | null; + isBinary: boolean; +} + +export interface SkillReviewPreview { + targetSkillDir: string; + changes: SkillReviewFileChange[]; + warnings: string[]; +} + +export interface SkillUpsertRequest { + scope: SkillScope; + rootKind: SkillRootKind; + projectPath?: string; + folderName: string; + existingSkillId?: string; + files: SkillDraftFile[]; +} + +export interface SkillImportRequest { + sourceDir: string; + scope: SkillScope; + rootKind: SkillRootKind; + projectPath?: string; + folderName?: string; +} + +export interface SkillDeleteRequest { + skillId: string; + projectPath?: string; +} + +export type CreateSkillRequest = SkillUpsertRequest; +export type UpdateSkillRequest = SkillUpsertRequest; +export type ImportSkillRequest = SkillImportRequest; +export type DeleteSkillRequest = SkillDeleteRequest; + +export interface SkillSaveResult { + skillId: string; + detail: SkillDetail | null; +} + +export interface SkillWatcherEvent { + scope: SkillScope; + projectPath: string | null; + path: string; + type: 'create' | 'change' | 'delete'; +} diff --git a/test/main/services/extensions/SkillMetadataParser.test.ts b/test/main/services/extensions/SkillMetadataParser.test.ts new file mode 100644 index 00000000..802abc05 --- /dev/null +++ b/test/main/services/extensions/SkillMetadataParser.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { SkillMetadataParser } from '@main/services/extensions/skills/SkillMetadataParser'; + +describe('SkillMetadataParser', () => { + const parser = new SkillMetadataParser(); + const root = { + scope: 'project' as const, + rootKind: 'claude' as const, + projectRoot: '/tmp/project', + rootPath: '/tmp/project/.claude/skills', + }; + + it('parses valid frontmatter and derives warnings', () => { + const item = parser.parseCatalogItem({ + skillDir: '/tmp/project/.claude/skills/demo-skill', + folderName: 'demo-skill', + skillFile: '/tmp/project/.claude/skills/demo-skill/Skill.md', + rawContent: `--- +name: demo-skill +description: Test skill +allowed-tools: + - Read +compatibility: Requires network and API key +unknown-key: true +--- + +# Demo`, + modifiedAt: 1, + flags: { hasScripts: true, hasReferences: false, hasAssets: false }, + root, + }); + + expect(item.isValid).toBe(true); + expect(item.issues.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'nonstandard-file-name', + 'unknown-frontmatter-keys', + 'has-scripts', + 'allowed-tools-advisory', + 'compatibility-advisory', + ]) + ); + }); + + it('marks missing frontmatter as invalid', () => { + const item = parser.parseCatalogItem({ + skillDir: '/tmp/project/.claude/skills/demo-skill', + folderName: 'demo-skill', + skillFile: '/tmp/project/.claude/skills/demo-skill/SKILL.md', + rawContent: '# No frontmatter', + modifiedAt: 1, + flags: { hasScripts: false, hasReferences: false, hasAssets: false }, + root, + }); + + expect(item.isValid).toBe(false); + expect(item.issues.map((issue) => issue.code)).toEqual( + expect.arrayContaining(['missing-frontmatter', 'missing-name', 'missing-description']) + ); + }); +}); diff --git a/test/main/services/extensions/SkillReviewService.test.ts b/test/main/services/extensions/SkillReviewService.test.ts new file mode 100644 index 00000000..57180f99 --- /dev/null +++ b/test/main/services/extensions/SkillReviewService.test.ts @@ -0,0 +1,32 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { SkillReviewService } from '@main/services/extensions/skills/SkillReviewService'; + +describe('SkillReviewService', () => { + const createdDirs: string[] = []; + + afterEach(async () => { + await Promise.all(createdDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + }); + + it('builds create/update review preview correctly', async () => { + const service = new SkillReviewService(); + const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-review-')); + createdDirs.push(targetDir); + + await fs.mkdir(path.join(targetDir, 'scripts'), { recursive: true }); + await fs.writeFile(path.join(targetDir, 'SKILL.md'), '# old', 'utf8'); + + const changes = await service.buildTextChanges(targetDir, [ + { relativePath: 'SKILL.md', content: '# new' }, + { relativePath: 'scripts/run.sh', content: 'echo hi' }, + ]); + + expect(changes.find((change) => change.relativePath === 'SKILL.md')?.action).toBe('update'); + expect(changes.find((change) => change.relativePath === 'scripts/run.sh')?.action).toBe('create'); + }); +}); diff --git a/test/main/services/extensions/SkillRootsResolver.test.ts b/test/main/services/extensions/SkillRootsResolver.test.ts new file mode 100644 index 00000000..2c87294a --- /dev/null +++ b/test/main/services/extensions/SkillRootsResolver.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { SkillRootsResolver } from '@main/services/extensions/skills/SkillRootsResolver'; + +describe('SkillRootsResolver', () => { + it('returns user roots when no project path is provided', () => { + const resolver = new SkillRootsResolver(); + + const roots = resolver.resolve(); + + expect(roots).toHaveLength(3); + expect(roots.every((root) => root.scope === 'user')).toBe(true); + expect(roots.map((root) => root.rootKind)).toEqual(['claude', 'cursor', 'agents']); + }); + + it('returns project and user roots when project path is provided', () => { + const resolver = new SkillRootsResolver(); + + const roots = resolver.resolve('/tmp/demo-project'); + + expect(roots).toHaveLength(6); + expect(roots.filter((root) => root.scope === 'project')).toHaveLength(3); + expect(roots.filter((root) => root.scope === 'user')).toHaveLength(3); + }); +}); diff --git a/test/main/services/extensions/SkillScaffoldService.test.ts b/test/main/services/extensions/SkillScaffoldService.test.ts new file mode 100644 index 00000000..68347846 --- /dev/null +++ b/test/main/services/extensions/SkillScaffoldService.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { SkillScaffoldService } from '@main/services/extensions/skills/SkillScaffoldService'; +import { SkillRootsResolver } from '@main/services/extensions/skills/SkillRootsResolver'; + +describe('SkillScaffoldService', () => { + it('normalizes valid relative draft file paths', () => { + const service = new SkillScaffoldService(); + + const files = service.normalizeDraftFiles([ + { relativePath: 'scripts/../scripts/run.sh', content: 'echo hi' }, + ]); + + expect(files[0]?.relativePath).toBe('scripts/run.sh'); + }); + + it('rejects path traversal in draft file paths', () => { + const service = new SkillScaffoldService(); + + expect(() => + service.normalizeDraftFiles([{ relativePath: '../escape.txt', content: 'nope' }]) + ).toThrow('Invalid relative path'); + }); + + it('rejects existing skill ids outside the selected root', async () => { + const resolver = new SkillRootsResolver(); + const service = new SkillScaffoldService(resolver); + + await expect( + service.resolveUpsertTarget( + 'project', + 'claude', + '/tmp/demo-project', + 'valid-name', + '/tmp/another-project/.claude/skills/foreign' + ) + ).rejects.toThrow('outside the allowed root'); + }); +}); diff --git a/test/main/services/extensions/SkillValidator.test.ts b/test/main/services/extensions/SkillValidator.test.ts new file mode 100644 index 00000000..513f6959 --- /dev/null +++ b/test/main/services/extensions/SkillValidator.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { SkillValidator } from '@main/services/extensions/skills/SkillValidator'; + +import type { SkillCatalogItem } from '@shared/types/extensions'; + +function makeSkill(overrides: Partial): SkillCatalogItem { + return { + id: '/tmp/skill', + sourceType: 'filesystem', + name: 'demo', + description: 'demo', + folderName: 'demo', + scope: 'user', + rootKind: 'claude', + projectRoot: null, + discoveryRoot: '/tmp/.claude/skills', + skillDir: '/tmp/.claude/skills/demo', + skillFile: '/tmp/.claude/skills/demo/SKILL.md', + metadata: {}, + invocationMode: 'auto', + flags: { hasScripts: false, hasReferences: false, hasAssets: false }, + isValid: true, + issues: [], + modifiedAt: 1, + ...overrides, + }; +} + +describe('SkillValidator', () => { + it('adds duplicate-name warnings across roots', () => { + const validator = new SkillValidator(); + + const result = validator.annotateCatalog([ + makeSkill({ id: '/a', scope: 'project', rootKind: 'claude' }), + makeSkill({ id: '/b', scope: 'user', rootKind: 'cursor' }), + ]); + + expect(result[0].issues.map((issue) => issue.code)).toContain('duplicate-name'); + expect(result[1].issues.map((issue) => issue.code)).toContain('duplicate-name'); + }); + + it('sorts by validity, scope, root precedence, then name', () => { + const validator = new SkillValidator(); + + const result = validator.annotateCatalog([ + makeSkill({ id: '/3', name: 'z-user', scope: 'user', rootKind: 'claude' }), + makeSkill({ id: '/2', name: 'b-project-cursor', scope: 'project', rootKind: 'cursor' }), + makeSkill({ id: '/1', name: 'a-project-claude', scope: 'project', rootKind: 'claude' }), + makeSkill({ + id: '/4', + name: 'invalid', + isValid: false, + issues: [{ code: 'missing-name', message: 'missing', severity: 'error' }], + }), + ]); + + expect(result.map((item) => item.id)).toEqual(['/1', '/2', '/3', '/4']); + }); +}); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 0a2a696a..c0c3728d 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -24,6 +24,18 @@ vi.mock('../../../src/renderer/api', () => ({ install: vi.fn(), uninstall: vi.fn(), }, + skills: { + list: vi.fn(), + getDetail: vi.fn(), + previewUpsert: vi.fn(), + applyUpsert: vi.fn(), + previewImport: vi.fn(), + applyImport: vi.fn(), + deleteSkill: vi.fn(), + startWatching: vi.fn(), + stopWatching: vi.fn(), + onChanged: vi.fn(), + }, }, })); @@ -183,6 +195,16 @@ describe('extensionsSlice', () => { expect(extTab!.label).toBe('Extensions'); }); + it('seeds projectId from activeProjectId when selectedProjectId is null', () => { + store.setState({ selectedProjectId: null, activeProjectId: 'project-active' }); + + store.getState().openExtensionsTab(); + + const tabs = store.getState().paneLayout.panes.flatMap((p) => p.tabs); + const extTab = tabs.find((t) => t.type === 'extensions'); + expect(extTab?.projectId).toBe('project-active'); + }); + it('activates existing extensions tab instead of creating new', () => { store.getState().openExtensionsTab(); const tabs1 = store.getState().paneLayout.panes.flatMap((p) => p.tabs);