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.
This commit is contained in:
parent
4fa739d887
commit
4b4dccd13d
39 changed files with 4062 additions and 18 deletions
137
docs/extensions/adr-002-skills-in-extensions.md
Normal file
137
docs/extensions/adr-002-skills-in-extensions.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
202
src/main/ipc/skills.ts
Normal file
202
src/main/ipc/skills.ts
Normal file
|
|
@ -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<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function wrapHandler<T>(name: string, fn: () => Promise<T> | T): Promise<IpcResult<T>> {
|
||||
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<IpcResult<SkillCatalogItem[]>> {
|
||||
return wrapHandler('skillsList', () =>
|
||||
getSkillsCatalogService().list(typeof projectPath === 'string' ? projectPath : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSkillsGetDetail(
|
||||
_event: IpcMainInvokeEvent,
|
||||
skillId?: string,
|
||||
projectPath?: string
|
||||
): Promise<IpcResult<SkillDetail | null>> {
|
||||
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<IpcResult<SkillReviewPreview>> {
|
||||
return wrapHandler('skillsPreviewUpsert', () => {
|
||||
if (!request) throw new Error('request is required');
|
||||
return getSkillsMutationService().previewUpsert(request);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSkillsApplyUpsert(
|
||||
_event: IpcMainInvokeEvent,
|
||||
request?: SkillUpsertRequest
|
||||
): Promise<IpcResult<SkillDetail | null>> {
|
||||
return wrapHandler('skillsApplyUpsert', () => {
|
||||
if (!request) throw new Error('request is required');
|
||||
return getSkillsMutationService().applyUpsert(request);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSkillsPreviewImport(
|
||||
_event: IpcMainInvokeEvent,
|
||||
request?: SkillImportRequest
|
||||
): Promise<IpcResult<SkillReviewPreview>> {
|
||||
return wrapHandler('skillsPreviewImport', () => {
|
||||
if (!request) throw new Error('request is required');
|
||||
return getSkillsMutationService().previewImport(request);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSkillsApplyImport(
|
||||
_event: IpcMainInvokeEvent,
|
||||
request?: SkillImportRequest
|
||||
): Promise<IpcResult<SkillDetail | null>> {
|
||||
return wrapHandler('skillsApplyImport', () => {
|
||||
if (!request) throw new Error('request is required');
|
||||
return getSkillsMutationService().applyImport(request);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSkillsDelete(
|
||||
_event: IpcMainInvokeEvent,
|
||||
request?: SkillDeleteRequest
|
||||
): Promise<IpcResult<void>> {
|
||||
return wrapHandler('skillsDelete', () => {
|
||||
if (!request) throw new Error('request is required');
|
||||
return getSkillsMutationService().deleteSkill(request);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSkillsStartWatching(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectPath?: string
|
||||
): Promise<IpcResult<string>> {
|
||||
return wrapHandler('skillsStartWatching', () =>
|
||||
getSkillsWatcherService().start(typeof projectPath === 'string' ? projectPath : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSkillsStopWatching(
|
||||
_event: IpcMainInvokeEvent,
|
||||
watchId?: string
|
||||
): Promise<IpcResult<void>> {
|
||||
return wrapHandler('skillsStopWatching', () => {
|
||||
if (typeof watchId !== 'string' || !watchId) {
|
||||
throw new Error('watchId is required');
|
||||
}
|
||||
return getSkillsWatcherService().stop(watchId);
|
||||
});
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
83
src/main/services/extensions/skills/SkillImportService.ts
Normal file
83
src/main/services/extensions/skills/SkillImportService.ts
Normal file
|
|
@ -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<string> {
|
||||
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<ImportedSkillSourceFile[]> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
295
src/main/services/extensions/skills/SkillMetadataParser.ts
Normal file
295
src/main/services/extensions/skills/SkillMetadataParser.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
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<string, string> {
|
||||
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';
|
||||
}
|
||||
}
|
||||
73
src/main/services/extensions/skills/SkillReviewService.ts
Normal file
73
src/main/services/extensions/skills/SkillReviewService.ts
Normal file
|
|
@ -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<SkillReviewFileChange[]> {
|
||||
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<SkillReviewFileChange[]> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await fs.stat(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/main/services/extensions/skills/SkillRootsResolver.ts
Normal file
46
src/main/services/extensions/skills/SkillRootsResolver.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
88
src/main/services/extensions/skills/SkillScaffoldService.ts
Normal file
88
src/main/services/extensions/skills/SkillScaffoldService.ts
Normal file
|
|
@ -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<string> {
|
||||
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<void> {
|
||||
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('/');
|
||||
}
|
||||
}
|
||||
117
src/main/services/extensions/skills/SkillScanner.ts
Normal file
117
src/main/services/extensions/skills/SkillScanner.ts
Normal file
|
|
@ -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<SkillCatalogItem[]> {
|
||||
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<string | null> {
|
||||
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<SkillDirectoryFlags> {
|
||||
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<SkillRelatedFiles> {
|
||||
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<string[]> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(targetDir);
|
||||
return stat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/main/services/extensions/skills/SkillValidator.ts
Normal file
64
src/main/services/extensions/skills/SkillValidator.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { SkillCatalogItem } from '@shared/types/extensions';
|
||||
|
||||
const ROOT_PRECEDENCE: Record<SkillCatalogItem['rootKind'], number> = {
|
||||
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<string, SkillCatalogItem[]>();
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
86
src/main/services/extensions/skills/SkillsCatalogService.ts
Normal file
86
src/main/services/extensions/skills/SkillsCatalogService.ts
Normal file
|
|
@ -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<SkillCatalogItem[]> {
|
||||
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<SkillDetail | null> {
|
||||
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<SkillCatalogItem[]> {
|
||||
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}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
136
src/main/services/extensions/skills/SkillsMutationService.ts
Normal file
136
src/main/services/extensions/skills/SkillsMutationService.ts
Normal file
|
|
@ -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<SkillReviewPreview> {
|
||||
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<SkillDetail | null> {
|
||||
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<SkillReviewPreview> {
|
||||
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<SkillDetail | null> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
134
src/main/services/extensions/skills/SkillsWatcherService.ts
Normal file
134
src/main/services/extensions/skills/SkillsWatcherService.ts
Normal file
|
|
@ -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<string, string | null>();
|
||||
private pendingEvents = new Map<string, SkillWatcherEvent>();
|
||||
private flushTimer: ReturnType<typeof setTimeout> | 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<string> {
|
||||
const watchId = `skills-watch-${++this.nextWatchId}`;
|
||||
this.subscriptions.set(watchId, projectPath ?? null);
|
||||
await this.rebuildWatcher();
|
||||
return watchId;
|
||||
}
|
||||
|
||||
async stop(watchId: string): Promise<void> {
|
||||
this.subscriptions.delete(watchId);
|
||||
await this.rebuildWatcher();
|
||||
}
|
||||
|
||||
private async rebuildWatcher(): Promise<void> {
|
||||
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<void> {
|
||||
this.subscriptions.clear();
|
||||
await this.rebuildWatcher();
|
||||
}
|
||||
|
||||
private enqueueEventsForPath(type: SkillWatcherEvent['type'], filePath: string): void {
|
||||
const matchedProjectPaths = new Set<string | null>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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<Record<string, number>>(MCP_GITHUB_STARS, repositoryUrls),
|
||||
},
|
||||
|
||||
// ===== Skills Catalog API (Electron-only) =====
|
||||
skills: {
|
||||
list: (projectPath?: string) =>
|
||||
invokeIpcWithResult<SkillCatalogItem[]>(SKILLS_LIST, projectPath),
|
||||
getDetail: (skillId: string, projectPath?: string) =>
|
||||
invokeIpcWithResult<SkillDetail | null>(SKILLS_GET_DETAIL, skillId, projectPath),
|
||||
previewUpsert: (request: SkillUpsertRequest) =>
|
||||
invokeIpcWithResult<SkillReviewPreview>(SKILLS_PREVIEW_UPSERT, request),
|
||||
applyUpsert: (request: SkillUpsertRequest) =>
|
||||
invokeIpcWithResult<SkillDetail | null>(SKILLS_APPLY_UPSERT, request),
|
||||
previewImport: (request: SkillImportRequest) =>
|
||||
invokeIpcWithResult<SkillReviewPreview>(SKILLS_PREVIEW_IMPORT, request),
|
||||
applyImport: (request: SkillImportRequest) =>
|
||||
invokeIpcWithResult<SkillDetail | null>(SKILLS_APPLY_IMPORT, request),
|
||||
deleteSkill: (request: SkillDeleteRequest) => invokeIpcWithResult<void>(SKILLS_DELETE, request),
|
||||
startWatching: (projectPath?: string) =>
|
||||
invokeIpcWithResult<string>(SKILLS_START_WATCHING, projectPath),
|
||||
stopWatching: (watchId: string) => invokeIpcWithResult<void>(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<ApiKeyEntry[]>(API_KEYS_LIST),
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -114,7 +140,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
<Tabs
|
||||
value={tabState.activeSubTab}
|
||||
onValueChange={(v) =>
|
||||
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'api-keys')
|
||||
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'skills' | 'api-keys')
|
||||
}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
|
|
@ -127,6 +153,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
<Server className="size-3.5" />
|
||||
MCP Servers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="skills" className="gap-1.5">
|
||||
<BookOpen className="size-3.5" />
|
||||
Skills
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="api-keys" className="gap-1.5">
|
||||
<Key className="size-3.5" />
|
||||
API Keys
|
||||
|
|
@ -176,6 +206,19 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
<TabsContent value="api-keys">
|
||||
<ApiKeysPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="skills">
|
||||
<SkillsPanel
|
||||
projectPath={projectPath}
|
||||
projectLabel={projectLabel}
|
||||
skillsSearchQuery={tabState.skillsSearchQuery}
|
||||
setSkillsSearchQuery={tabState.setSkillsSearchQuery}
|
||||
skillsSort={tabState.skillsSort}
|
||||
setSkillsSort={tabState.setSkillsSort}
|
||||
selectedSkillId={tabState.selectedSkillId}
|
||||
setSelectedSkillId={tabState.setSelectedSkillId}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Custom MCP server dialog (lifted to store view level) */}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(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 <div ref={containerRef} className="h-full min-h-0" />;
|
||||
};
|
||||
208
src/renderer/components/extensions/skills/SkillDetailDialog.tsx
Normal file
208
src/renderer/components/extensions/skills/SkillDetailDialog.tsx
Normal file
|
|
@ -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<void> {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{item?.name ?? 'Skill details'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{item?.description ?? 'Inspect discovered skill metadata and raw instructions.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{(loading || (open && skillId && detail === undefined)) && (
|
||||
<p className="text-sm text-text-muted">Loading skill details...</p>
|
||||
)}
|
||||
|
||||
{!loading && detail === null && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
|
||||
Unable to load this skill.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && detail && item && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{item.scope}</Badge>
|
||||
<Badge variant="outline">{formatRootKind(item.rootKind)}</Badge>
|
||||
<Badge variant="secondary">{item.invocationMode}</Badge>
|
||||
{item.flags.hasScripts && <Badge variant="destructive">scripts</Badge>}
|
||||
{item.flags.hasReferences && <Badge variant="secondary">references</Badge>}
|
||||
{item.flags.hasAssets && <Badge variant="secondary">assets</Badge>}
|
||||
</div>
|
||||
|
||||
{item.issues.length > 0 && (
|
||||
<div className="space-y-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-4">
|
||||
{item.issues.map((issue, index) => (
|
||||
<div
|
||||
key={`${issue.code}-${index}`}
|
||||
className="flex gap-2 text-sm text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{issue.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onEdit}>
|
||||
<Pencil className="mr-1.5 size-3.5" />
|
||||
Edit Skill
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void api.showInFolder(item.skillFile)}
|
||||
>
|
||||
<FolderOpen className="mr-1.5 size-3.5" />
|
||||
Open Folder
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void api.openPath(item.skillFile, projectPath ?? undefined)}
|
||||
>
|
||||
<ExternalLink className="mr-1.5 size-3.5" />
|
||||
Open SKILL.md
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={skillsMutationLoading}
|
||||
>
|
||||
<Trash2 className="mr-1.5 size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="min-w-0 rounded-lg border border-border p-4">
|
||||
<MarkdownViewer
|
||||
content={detail.body || detail.rawContent}
|
||||
baseDir={item.skillDir}
|
||||
bare
|
||||
copyable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<CodeBlockViewer
|
||||
fileName={item.skillFile}
|
||||
content={detail.rawContent}
|
||||
maxHeight="max-h-72"
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-border p-3 text-sm text-text-secondary">
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-text">Path</p>
|
||||
<p className="break-all text-xs text-text-muted">{item.skillDir}</p>
|
||||
</div>
|
||||
|
||||
{detail.scriptFiles.length > 0 && (
|
||||
<div className="mt-4 space-y-1">
|
||||
<p className="font-medium text-text">Scripts</p>
|
||||
{detail.scriptFiles.map((file) => (
|
||||
<p key={file} className="text-xs text-text-muted">
|
||||
{file}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.referencesFiles.length > 0 && (
|
||||
<div className="mt-4 space-y-1">
|
||||
<p className="font-medium text-text">References</p>
|
||||
{detail.referencesFiles.map((file) => (
|
||||
<p key={file} className="text-xs text-text-muted">
|
||||
{file}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.assetFiles.length > 0 && (
|
||||
<div className="mt-4 space-y-1">
|
||||
<p className="font-medium text-text">Assets</p>
|
||||
{detail.assetFiles.map((file) => (
|
||||
<p key={file} className="text-xs text-text-muted">
|
||||
{file}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
579
src/renderer/components/extensions/skills/SkillEditorDialog.tsx
Normal file
579
src/renderer/components/extensions/skills/SkillEditorDialog.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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<SkillInvocationMode>('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<SkillReviewPreview | null>(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<void> {
|
||||
const preview = await previewSkillUpsert(request);
|
||||
setReviewPreview(preview);
|
||||
setReviewOpen(true);
|
||||
}
|
||||
|
||||
async function handleConfirmSave(): Promise<void> {
|
||||
const saved = await applySkillUpsert(request);
|
||||
setReviewOpen(false);
|
||||
onSaved(saved?.item.id ?? detail?.item.id ?? null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-w-6xl gap-0 overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] min-h-0 flex-col">
|
||||
<DialogHeader className="border-b border-border px-6 py-5">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{descriptionText}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-scope">Scope</Label>
|
||||
<Select
|
||||
value={scope}
|
||||
onValueChange={(value) => setScope(value as 'user' | 'project')}
|
||||
disabled={mode === 'edit'}
|
||||
>
|
||||
<SelectTrigger id="skill-scope">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="project" disabled={!canUseProjectScope}>
|
||||
{canUseProjectScope
|
||||
? `Project: ${projectLabel ?? projectPath}`
|
||||
: 'Project unavailable'}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-root">Root</Label>
|
||||
<Select
|
||||
value={rootKind}
|
||||
onValueChange={(value) =>
|
||||
setRootKind(value as 'claude' | 'cursor' | 'agents')
|
||||
}
|
||||
disabled={mode === 'edit'}
|
||||
>
|
||||
<SelectTrigger id="skill-root">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">.claude</SelectItem>
|
||||
<SelectItem value="cursor">.cursor</SelectItem>
|
||||
<SelectItem value="agents">.agents</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-folder">Folder name</Label>
|
||||
<Input
|
||||
id="skill-folder"
|
||||
value={folderName}
|
||||
onChange={(event) => setFolderName(event.target.value)}
|
||||
disabled={mode === 'edit'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-invocation">Invocation</Label>
|
||||
<Select
|
||||
value={invocationMode}
|
||||
onValueChange={(value) => {
|
||||
const nextValue = value as SkillInvocationMode;
|
||||
setInvocationMode(nextValue);
|
||||
applyMetadataToRawContent({ invocationMode: nextValue });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="skill-invocation">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="manual-only">Manual only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-name">Skill name</Label>
|
||||
<Input
|
||||
id="skill-name"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setName(nextValue);
|
||||
applyMetadataToRawContent({ name: nextValue });
|
||||
}}
|
||||
placeholder="Write concise skill name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-license">License</Label>
|
||||
<Input
|
||||
id="skill-license"
|
||||
value={license}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setLicense(nextValue);
|
||||
applyMetadataToRawContent({ license: nextValue });
|
||||
}}
|
||||
placeholder="MIT"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-description">Description</Label>
|
||||
<Input
|
||||
id="skill-description"
|
||||
value={description}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setDescription(nextValue);
|
||||
applyMetadataToRawContent({ description: nextValue });
|
||||
}}
|
||||
placeholder="What this skill helps with"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-compatibility">Compatibility</Label>
|
||||
<Input
|
||||
id="skill-compatibility"
|
||||
value={compatibility}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setCompatibility(nextValue);
|
||||
applyMetadataToRawContent({ compatibility: nextValue });
|
||||
}}
|
||||
placeholder="claude-code, cursor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-text">Optional files</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Add starter files that will be included in the review and written together
|
||||
with `SKILL.md`.
|
||||
</p>
|
||||
</div>
|
||||
{mode === 'edit' && (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
Root and folder are locked for edits
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<label className="bg-surface-raised/10 flex cursor-pointer items-start gap-3 rounded-lg border border-border p-3 text-sm">
|
||||
<Checkbox
|
||||
checked={includeReferences}
|
||||
onCheckedChange={(value) => setIncludeReferences(Boolean(value))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-text">References</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Add `references/README.md` for docs, links, and examples.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="bg-surface-raised/10 flex cursor-pointer items-start gap-3 rounded-lg border border-border p-3 text-sm">
|
||||
<Checkbox
|
||||
checked={includeScripts}
|
||||
onCheckedChange={(value) => setIncludeScripts(Boolean(value))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-text">Scripts</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Add `scripts/README.md` for helper commands or setup notes.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="bg-surface-raised/10 flex cursor-pointer items-start gap-3 rounded-lg border border-border p-3 text-sm">
|
||||
<Checkbox
|
||||
checked={includeAssets}
|
||||
onCheckedChange={(value) => setIncludeAssets(Boolean(value))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-text">Assets</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Add `assets/README.md` for screenshots or bundled media.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{auxiliaryDraftFilePaths.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
|
||||
Added files:
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{auxiliaryDraftFilePaths.map((filePath) => (
|
||||
<Badge key={filePath} variant="outline" className="font-normal">
|
||||
{filePath}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{skillsMutationError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400">
|
||||
{skillsMutationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="skill-raw">SKILL.md</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setManualRawEdit(false);
|
||||
const nextRawContent = buildSkillTemplate({
|
||||
name,
|
||||
description,
|
||||
license,
|
||||
compatibility,
|
||||
invocationMode,
|
||||
});
|
||||
rawContentRef.current = nextRawContent;
|
||||
setRawContent(nextRawContent);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 size-3.5" />
|
||||
Reset From Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex h-[520px] min-h-0 overflow-hidden rounded-lg border border-border"
|
||||
>
|
||||
<div className="min-w-0" style={{ width: `${splitRatio * 100}%` }}>
|
||||
<SkillCodeEditor
|
||||
value={rawContent}
|
||||
onChange={(value) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`w-1 shrink-0 cursor-col-resize border-x border-border ${
|
||||
isResizing ? 'bg-blue-500/50' : 'hover:bg-blue-500/30'
|
||||
}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
setIsResizing(true);
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<MarkdownPreviewPane content={rawContent} baseDir={detail?.item.skillDir} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-border bg-surface px-6 py-4 shadow-[0_-8px_24px_rgba(0,0,0,0.08)]">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<X className="mr-1.5 size-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<p className="min-w-[16rem] flex-1 text-sm text-text-muted">
|
||||
Review the file changes first, then confirm save in the next step.
|
||||
</p>
|
||||
<Button onClick={() => void handleReview()} disabled={skillsMutationLoading}>
|
||||
<FileSearch className="mr-1.5 size-3.5" />
|
||||
{skillsMutationLoading
|
||||
? 'Preparing...'
|
||||
: mode === 'create'
|
||||
? 'Review And Create'
|
||||
: 'Review And Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SkillReviewDialog
|
||||
open={reviewOpen}
|
||||
preview={reviewPreview}
|
||||
loading={skillsMutationLoading}
|
||||
onClose={() => setReviewOpen(false)}
|
||||
onConfirm={() => void handleConfirmSave()}
|
||||
confirmLabel={mode === 'create' ? 'Create Skill' : 'Save Skill'}
|
||||
reviewLabel={mode === 'create' ? 'Creating a skill' : 'Saving this skill'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
222
src/renderer/components/extensions/skills/SkillImportDialog.tsx
Normal file
222
src/renderer/components/extensions/skills/SkillImportDialog.tsx
Normal file
|
|
@ -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<SkillReviewPreview | null>(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<void> {
|
||||
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<void> {
|
||||
const nextPreview = await previewSkillImport({
|
||||
sourceDir,
|
||||
folderName: folderName || undefined,
|
||||
scope,
|
||||
rootKind,
|
||||
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
|
||||
});
|
||||
setPreview(nextPreview);
|
||||
setReviewOpen(true);
|
||||
}
|
||||
|
||||
async function handleConfirmImport(): Promise<void> {
|
||||
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 (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="gap-0 overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] min-h-0 flex-col">
|
||||
<DialogHeader className="border-b border-border px-6 py-5">
|
||||
<DialogTitle>Import skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick an existing skill folder, review the copy plan, then import it into a supported
|
||||
root.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-import-source">Source folder</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="skill-import-source"
|
||||
value={sourceDir}
|
||||
onChange={(event) => setSourceDir(event.target.value)}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => void handleChooseFolder()}>
|
||||
<FolderOpen className="mr-1.5 size-3.5" />
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-import-folder">Destination folder name</Label>
|
||||
<Input
|
||||
id="skill-import-folder"
|
||||
value={folderName}
|
||||
onChange={(event) => setFolderName(event.target.value)}
|
||||
placeholder="Defaults to source folder name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-import-scope">Scope</Label>
|
||||
<Select
|
||||
value={scope}
|
||||
onValueChange={(value) => setScope(value as 'user' | 'project')}
|
||||
>
|
||||
<SelectTrigger id="skill-import-scope">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="project" disabled={!projectPath}>
|
||||
{projectPath
|
||||
? `Project: ${projectLabel ?? projectPath}`
|
||||
: 'Project unavailable'}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-import-root">Root</Label>
|
||||
<Select
|
||||
value={rootKind}
|
||||
onValueChange={(value) =>
|
||||
setRootKind(value as 'claude' | 'cursor' | 'agents')
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="skill-import-root">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">.claude</SelectItem>
|
||||
<SelectItem value="cursor">.cursor</SelectItem>
|
||||
<SelectItem value="agents">.agents</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{skillsMutationError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400">
|
||||
{skillsMutationError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-border bg-surface px-6 py-4 shadow-[0_-8px_24px_rgba(0,0,0,0.08)]">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<X className="mr-1.5 size-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<p className="min-w-[16rem] flex-1 text-sm text-text-muted">
|
||||
Review the copied files first, then confirm the import in the next step.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => void handleReview()}
|
||||
disabled={!sourceDir || skillsMutationLoading}
|
||||
>
|
||||
<FileSearch className="mr-1.5 size-3.5" />
|
||||
{skillsMutationLoading ? 'Preparing...' : 'Review And Import'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SkillReviewDialog
|
||||
open={reviewOpen}
|
||||
preview={preview}
|
||||
loading={skillsMutationLoading}
|
||||
onClose={() => setReviewOpen(false)}
|
||||
onConfirm={() => void handleConfirmImport()}
|
||||
confirmLabel="Import Skill"
|
||||
reviewLabel="Importing this skill"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
135
src/renderer/components/extensions/skills/SkillReviewDialog.tsx
Normal file
135
src/renderer/components/extensions/skills/SkillReviewDialog.tsx
Normal file
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-w-[min(96vw,80rem)] gap-0 overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] min-h-0 min-w-0 flex-col">
|
||||
<DialogHeader className="border-b border-border px-6 py-5">
|
||||
<DialogTitle>Review skill changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
{reviewLabel} previews the filesystem changes first. Nothing is written until you
|
||||
confirm below.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-6 py-5">
|
||||
{!preview && <p className="text-sm text-text-muted">No preview available.</p>}
|
||||
|
||||
{preview && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-surface-raised/10 rounded-lg border border-border p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{preview.changes.length} file changes</Badge>
|
||||
</div>
|
||||
<div className="mt-3 break-all rounded-md border border-border bg-surface px-3 py-2 font-mono text-xs text-text-muted">
|
||||
{preview.targetSkillDir}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-text-muted">
|
||||
Review the diff below, then use{' '}
|
||||
<span className="font-medium text-text">{confirmLabel}</span> to apply these
|
||||
changes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{preview.warnings.length > 0 && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
{preview.warnings.map((warning, index) => (
|
||||
<p key={`${warning}-${index}`}>{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasChanges && (
|
||||
<div className="bg-surface-raised/10 rounded-md border border-border p-4 text-sm text-text-muted">
|
||||
No file changes detected yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{preview.changes.map((change) => (
|
||||
<div
|
||||
key={change.absolutePath}
|
||||
className="min-w-0 overflow-hidden rounded-lg border border-border p-3"
|
||||
>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Badge variant={change.action === 'create' ? 'secondary' : 'outline'}>
|
||||
{change.action}
|
||||
</Badge>
|
||||
<span className="text-sm text-text">{change.relativePath}</span>
|
||||
{change.isBinary && <Badge variant="destructive">binary</Badge>}
|
||||
</div>
|
||||
|
||||
{change.isBinary ? (
|
||||
<div className="bg-surface-raised/20 rounded-md border border-border p-3 text-sm text-text-muted">
|
||||
Binary file preview is not shown. The file will be copied as-is.
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<DiffViewer
|
||||
fileName={change.relativePath}
|
||||
oldString={change.oldContent ?? ''}
|
||||
newString={change.newContent ?? ''}
|
||||
maxHeight="max-h-80"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-10 flex items-center justify-between gap-3 border-t border-border bg-surface px-6 py-4 shadow-[0_-8px_24px_rgba(0,0,0,0.08)]">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<ChevronLeft className="mr-1.5 size-3.5" />
|
||||
Back To Editor
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={loading || !preview || !hasChanges}>
|
||||
{loading ? (
|
||||
<Save className="mr-1.5 size-3.5" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-1.5 size-3.5" />
|
||||
)}
|
||||
{loading ? 'Saving...' : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
395
src/renderer/components/extensions/skills/SkillsPanel.tsx
Normal file
395
src/renderer/components/extensions/skills/SkillsPanel.tsx
Normal file
|
|
@ -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<string | null>(null);
|
||||
const selectedSkillIdRef = useRef<string | null>(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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-surface-raised/20 rounded-xl border border-border p-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-1 xl:max-w-2xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="size-4 text-text-muted" />
|
||||
<h2 className="text-sm font-semibold text-text">Local skills catalog</h2>
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm leading-5 text-text-muted">
|
||||
{projectPath
|
||||
? `Project skills for ${projectLabel ?? projectPath} plus your user-level skills.`
|
||||
: 'User-level skills only. Select a project to include project-scoped skill roots.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-3 xl:w-auto xl:min-w-[32rem] xl:max-w-[40rem]">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center xl:justify-end">
|
||||
<div className="w-full lg:min-w-[18rem] lg:flex-1 xl:w-80 xl:flex-none">
|
||||
<SearchInput
|
||||
value={skillsSearchQuery}
|
||||
onChange={setSkillsSearchQuery}
|
||||
placeholder="Search skills..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
Create Skill
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<Download className="mr-1.5 size-3.5" />
|
||||
Import
|
||||
</Button>
|
||||
<Popover open={sortMenuOpen} onOpenChange={setSortMenuOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9 shrink-0"
|
||||
aria-label="Sort skills"
|
||||
>
|
||||
<ArrowUpDown className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Sort skills</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="w-44 p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-sm text-text hover:bg-surface-raised"
|
||||
onClick={() => {
|
||||
setSkillsSort('name-asc');
|
||||
setSortMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ArrowUpAZ className="mr-2 size-3.5" />
|
||||
Name
|
||||
{skillsSort === 'name-asc' && <Check className="ml-auto size-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-sm text-text hover:bg-surface-raised"
|
||||
onClick={() => {
|
||||
setSkillsSort('recent-desc');
|
||||
setSortMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Clock3 className="mr-2 size-3.5" />
|
||||
Recent
|
||||
{skillsSort === 'recent-desc' && <Check className="ml-auto size-3.5" />}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-text-muted xl:justify-end">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{mergedSkills.length} discovered
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{projectSkills.length} project
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{userSkills.length} user
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{skillsError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
|
||||
{skillsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-green-500/20 bg-green-500/10 p-4 text-sm text-green-700 dark:text-green-400">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span>{successMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skillsLoading && visibleSkills.length === 0 && (
|
||||
<div className="rounded-lg border border-border p-6 text-sm text-text-muted">
|
||||
Loading skills...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!skillsLoading && !skillsError && visibleSkills.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||
<Search className="size-5 text-text-muted" />
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{skillsSearchQuery ? 'No skills match your search' : 'No local skills found'}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{skillsSearchQuery
|
||||
? 'Try a different search term.'
|
||||
: 'Skills are discovered from .claude/skills, .cursor/skills, and .agents/skills roots.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleSkills.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{visibleSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSkillId(skill.id)}
|
||||
className="bg-surface-raised/10 rounded-xl border border-border p-4 text-left transition-colors hover:border-border-emphasis"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-text">{skill.name}</h3>
|
||||
{!skill.isValid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
Needs attention
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-text-secondary">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{skill.scope}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{formatRootKind(skill.rootKind)}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{skill.invocationMode}
|
||||
</Badge>
|
||||
{skill.flags.hasScripts && (
|
||||
<Badge variant="destructive" className="font-normal">
|
||||
scripts
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasReferences && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
references
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasAssets && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
assets
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{skill.issues.length > 0 && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{skill.issues[0]?.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SkillDetailDialog
|
||||
skillId={selectedSkillId}
|
||||
open={selectedSkillId !== null}
|
||||
onClose={() => setSelectedSkillId(null)}
|
||||
projectPath={projectPath}
|
||||
onEdit={() => setEditOpen(true)}
|
||||
onDeleted={() => setSelectedSkillId(null)}
|
||||
/>
|
||||
|
||||
<SkillEditorDialog
|
||||
open={createOpen}
|
||||
mode="create"
|
||||
projectPath={projectPath}
|
||||
projectLabel={projectLabel}
|
||||
detail={null}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSaved={(skillId) => {
|
||||
setCreateOpen(false);
|
||||
setSuccessMessage('Skill created successfully.');
|
||||
setSelectedSkillId(skillId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SkillEditorDialog
|
||||
open={editOpen}
|
||||
mode="edit"
|
||||
projectPath={projectPath}
|
||||
projectLabel={projectLabel}
|
||||
detail={selectedDetail}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSaved={(skillId) => {
|
||||
setEditOpen(false);
|
||||
setSuccessMessage('Skill saved successfully.');
|
||||
setSelectedSkillId(skillId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SkillImportDialog
|
||||
open={importOpen}
|
||||
projectPath={projectPath}
|
||||
projectLabel={projectLabel}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImported={(skillId) => {
|
||||
setImportOpen(false);
|
||||
setSuccessMessage('Skill imported successfully.');
|
||||
setSelectedSkillId(skillId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
147
src/renderer/components/extensions/skills/skillDraftUtils.ts
Normal file
147
src/renderer/components/extensions/skills/skillDraftUtils.ts
Normal file
|
|
@ -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<SkillDraftTemplateInput> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown> = {};
|
||||
if (match) {
|
||||
try {
|
||||
const parsed = YAML.parse(match[1]);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
data = { ...(parsed as Record<string, unknown>) };
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
|
@ -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<string[]>([]);
|
||||
const [selectedMcpServerId, setSelectedMcpServerId] = useState<string | null>(null);
|
||||
|
||||
// ── Skills browse ──
|
||||
const [skillsSearchQuery, setSkillsSearchQuery] = useState('');
|
||||
const [skillsInstalledOnly] = useState(false);
|
||||
const [skillsSort, setSkillsSort] = useState<SkillsSortState>('name-asc');
|
||||
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(null);
|
||||
|
||||
// ── Debounced MCP search ──
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -163,5 +170,14 @@ export function useExtensionsTabState() {
|
|||
mcpSearchWarnings,
|
||||
selectedMcpServerId,
|
||||
setSelectedMcpServerId,
|
||||
|
||||
// Skills
|
||||
skillsSearchQuery,
|
||||
setSkillsSearchQuery,
|
||||
skillsInstalledOnly,
|
||||
skillsSort,
|
||||
setSkillsSort,
|
||||
selectedSkillId,
|
||||
setSelectedSkillId,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, SkillCatalogItem[]>;
|
||||
skillsLoading: boolean;
|
||||
skillsError: string | null;
|
||||
skillsDetailsById: Record<string, SkillDetail | null | undefined>;
|
||||
skillsDetailLoadingById: Record<string, boolean>;
|
||||
skillsMutationLoading: boolean;
|
||||
skillsMutationError: string | null;
|
||||
|
||||
// ── GitHub Stars (supplementary) ──
|
||||
mcpGitHubStars: Record<string, number>;
|
||||
|
||||
|
|
@ -68,6 +84,13 @@ export interface ExtensionsSlice {
|
|||
mcpBrowse: (cursor?: string) => Promise<void>;
|
||||
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
|
||||
runMcpDiagnostics: () => Promise<void>;
|
||||
fetchSkillsCatalog: (projectPath?: string) => Promise<void>;
|
||||
fetchSkillDetail: (skillId: string, projectPath?: string) => Promise<void>;
|
||||
previewSkillUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
|
||||
applySkillUpsert: (request: SkillUpsertRequest) => Promise<SkillDetail | null>;
|
||||
previewSkillImport: (request: SkillImportRequest) => Promise<SkillReviewPreview>;
|
||||
applySkillImport: (request: SkillImportRequest) => Promise<SkillDetail | null>;
|
||||
deleteSkill: (request: SkillDeleteRequest) => Promise<void>;
|
||||
|
||||
// ── Mutation actions ──
|
||||
installPlugin: (request: PluginInstallRequest) => Promise<void>;
|
||||
|
|
@ -137,6 +160,15 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
apiKeySaving: false,
|
||||
apiKeyStorageStatus: null,
|
||||
|
||||
skillsUserCatalog: [],
|
||||
skillsProjectCatalogByProjectPath: {},
|
||||
skillsLoading: false,
|
||||
skillsError: null,
|
||||
skillsDetailsById: {},
|
||||
skillsDetailLoadingById: {},
|
||||
skillsMutationLoading: false,
|
||||
skillsMutationError: null,
|
||||
|
||||
mcpGitHubStars: {},
|
||||
|
||||
// ── Plugin catalog fetch ──
|
||||
|
|
@ -282,6 +314,156 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
await promise;
|
||||
},
|
||||
|
||||
fetchSkillsCatalog: async (projectPath?: string) => {
|
||||
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<AppState, [], [], ExtensionsSli
|
|||
state.openTab({
|
||||
type: 'extensions',
|
||||
label: 'Extensions',
|
||||
projectId: state.selectedProjectId ?? state.activeProjectId ?? undefined,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
import type { CliArgsValidationResult } from '../utils/cliArgsParser';
|
||||
import type { CliInstallerAPI } from './cliInstaller';
|
||||
import type { EditorAPI, ProjectAPI } from './editor';
|
||||
import type { McpCatalogAPI, PluginCatalogAPI, ApiKeysAPI } from './extensions';
|
||||
import type { McpCatalogAPI, PluginCatalogAPI, ApiKeysAPI, SkillsCatalogAPI } from './extensions';
|
||||
import type {
|
||||
AppConfig,
|
||||
DetectedError,
|
||||
|
|
@ -814,6 +814,9 @@ export interface ElectronAPI {
|
|||
// Extension Store — MCP Registry API (Electron-only, optional)
|
||||
mcpRegistry?: McpCatalogAPI;
|
||||
|
||||
// Extension Store — Skills Catalog API (Electron-only, optional)
|
||||
skills?: SkillsCatalogAPI;
|
||||
|
||||
// Extension Store — API Keys Management (Electron-only, optional)
|
||||
apiKeys?: ApiKeysAPI;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ import type {
|
|||
McpServerDiagnostic,
|
||||
McpSearchResult,
|
||||
} from './mcp';
|
||||
import type {
|
||||
SkillCatalogItem,
|
||||
SkillDeleteRequest,
|
||||
SkillDetail,
|
||||
SkillImportRequest,
|
||||
SkillReviewPreview,
|
||||
SkillUpsertRequest,
|
||||
SkillWatcherEvent,
|
||||
} from './skill';
|
||||
|
||||
// ── Plugin API ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -50,6 +59,21 @@ export interface McpCatalogAPI {
|
|||
githubStars: (repositoryUrls: string[]) => Promise<Record<string, number>>;
|
||||
}
|
||||
|
||||
// ── Skills API ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SkillsCatalogAPI {
|
||||
list: (projectPath?: string) => Promise<SkillCatalogItem[]>;
|
||||
getDetail: (skillId: string, projectPath?: string) => Promise<SkillDetail | null>;
|
||||
previewUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
|
||||
applyUpsert: (request: SkillUpsertRequest) => Promise<SkillDetail | null>;
|
||||
previewImport: (request: SkillImportRequest) => Promise<SkillReviewPreview>;
|
||||
applyImport: (request: SkillImportRequest) => Promise<SkillDetail | null>;
|
||||
deleteSkill: (request: SkillDeleteRequest) => Promise<void>;
|
||||
startWatching: (projectPath?: string) => Promise<string>;
|
||||
stopWatching: (watchId: string) => Promise<void>;
|
||||
onChanged: (callback: (event: SkillWatcherEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
// ── API Keys API ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiKeysAPI {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
144
src/shared/types/extensions/skill.ts
Normal file
144
src/shared/types/extensions/skill.ts
Normal file
|
|
@ -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<string, string>;
|
||||
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';
|
||||
}
|
||||
62
test/main/services/extensions/SkillMetadataParser.test.ts
Normal file
62
test/main/services/extensions/SkillMetadataParser.test.ts
Normal file
|
|
@ -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'])
|
||||
);
|
||||
});
|
||||
});
|
||||
32
test/main/services/extensions/SkillReviewService.test.ts
Normal file
32
test/main/services/extensions/SkillReviewService.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
25
test/main/services/extensions/SkillRootsResolver.test.ts
Normal file
25
test/main/services/extensions/SkillRootsResolver.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
39
test/main/services/extensions/SkillScaffoldService.test.ts
Normal file
39
test/main/services/extensions/SkillScaffoldService.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
60
test/main/services/extensions/SkillValidator.test.ts
Normal file
60
test/main/services/extensions/SkillValidator.test.ts
Normal file
|
|
@ -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>): 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue