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",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"yet-another-react-lightbox": "^3.29.1",
|
"yet-another-react-lightbox": "^3.29.1",
|
||||||
"zustand": "^4.5.0"
|
"zustand": "^4.5.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,9 @@ importers:
|
||||||
unified:
|
unified:
|
||||||
specifier: ^11.0.5
|
specifier: ^11.0.5
|
||||||
version: 11.0.5
|
version: 11.0.5
|
||||||
|
yaml:
|
||||||
|
specifier: ^2.8.2
|
||||||
|
version: 2.8.2
|
||||||
yet-another-react-lightbox:
|
yet-another-react-lightbox:
|
||||||
specifier: ^3.29.1
|
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)
|
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 {
|
import {
|
||||||
CONTEXT_CHANGED,
|
CONTEXT_CHANGED,
|
||||||
SCHEDULE_CHANGE,
|
SCHEDULE_CHANGE,
|
||||||
|
SKILLS_CHANGED,
|
||||||
SSH_STATUS,
|
SSH_STATUS,
|
||||||
TEAM_CHANGE,
|
TEAM_CHANGE,
|
||||||
TEAM_TOOL_APPROVAL_EVENT,
|
TEAM_TOOL_APPROVAL_EVENT,
|
||||||
|
|
@ -84,6 +85,9 @@ import {
|
||||||
PluginCatalogService,
|
PluginCatalogService,
|
||||||
PluginInstallationStateService,
|
PluginInstallationStateService,
|
||||||
PluginInstallService,
|
PluginInstallService,
|
||||||
|
SkillsCatalogService,
|
||||||
|
SkillsMutationService,
|
||||||
|
SkillsWatcherService,
|
||||||
} from './services/extensions';
|
} from './services/extensions';
|
||||||
|
|
||||||
import type { FileChangeEvent } from '@main/types';
|
import type { FileChangeEvent } from '@main/types';
|
||||||
|
|
@ -339,6 +343,7 @@ let cliInstallerService: CliInstallerService;
|
||||||
let ptyTerminalService: PtyTerminalService;
|
let ptyTerminalService: PtyTerminalService;
|
||||||
let httpServer: HttpServer;
|
let httpServer: HttpServer;
|
||||||
let schedulerService: SchedulerService;
|
let schedulerService: SchedulerService;
|
||||||
|
let skillsWatcherService: SkillsWatcherService | null = null;
|
||||||
|
|
||||||
// File watcher event cleanup functions
|
// File watcher event cleanup functions
|
||||||
let fileChangeCleanup: (() => void) | null = null;
|
let fileChangeCleanup: (() => void) | null = null;
|
||||||
|
|
@ -713,6 +718,9 @@ function initializeServices(): void {
|
||||||
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
|
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
|
||||||
const mcpStateService = new McpInstallationStateService();
|
const mcpStateService = new McpInstallationStateService();
|
||||||
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(null);
|
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(null);
|
||||||
|
const skillsCatalogService = new SkillsCatalogService();
|
||||||
|
const skillsMutationService = new SkillsMutationService();
|
||||||
|
skillsWatcherService = new SkillsWatcherService();
|
||||||
const extensionFacadeService = new ExtensionFacadeService(
|
const extensionFacadeService = new ExtensionFacadeService(
|
||||||
pluginCatalogService,
|
pluginCatalogService,
|
||||||
pluginStateService,
|
pluginStateService,
|
||||||
|
|
@ -744,6 +752,12 @@ function initializeServices(): void {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
skillsWatcherService.setEmitter((event) => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send(SKILLS_CHANGED, event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
teamProvisioningService.setToolApprovalEventEmitter((event) => {
|
teamProvisioningService.setToolApprovalEventEmitter((event) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send(TEAM_TOOL_APPROVAL_EVENT, event);
|
mainWindow.webContents.send(TEAM_TOOL_APPROVAL_EVENT, event);
|
||||||
|
|
@ -786,6 +800,9 @@ function initializeServices(): void {
|
||||||
mcpInstallService,
|
mcpInstallService,
|
||||||
apiKeyService,
|
apiKeyService,
|
||||||
mcpHealthDiagnosticsService,
|
mcpHealthDiagnosticsService,
|
||||||
|
skillsCatalogService,
|
||||||
|
skillsMutationService,
|
||||||
|
skillsWatcherService,
|
||||||
crossTeamService
|
crossTeamService
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -900,6 +917,8 @@ function shutdownServices(): void {
|
||||||
void schedulerService.stop();
|
void schedulerService.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void skillsWatcherService?.stopAll();
|
||||||
|
|
||||||
// Kill all PTY processes
|
// Kill all PTY processes
|
||||||
if (ptyTerminalService) {
|
if (ptyTerminalService) {
|
||||||
ptyTerminalService.killAll();
|
ptyTerminalService.killAll();
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ import {
|
||||||
removeSessionHandlers,
|
removeSessionHandlers,
|
||||||
} from './sessions';
|
} from './sessions';
|
||||||
import { initializeSshHandlers, registerSshHandlers, removeSshHandlers } from './ssh';
|
import { initializeSshHandlers, registerSshHandlers, removeSshHandlers } from './ssh';
|
||||||
|
import { initializeSkillsHandlers, registerSkillsHandlers, removeSkillsHandlers } from './skills';
|
||||||
import {
|
import {
|
||||||
initializeSubagentHandlers,
|
initializeSubagentHandlers,
|
||||||
registerSubagentHandlers,
|
registerSubagentHandlers,
|
||||||
|
|
@ -109,6 +110,9 @@ import type { McpInstallService } from '../services/extensions/install/McpInstal
|
||||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||||
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||||
import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService';
|
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';
|
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -143,6 +147,9 @@ export function initializeIpcHandlers(
|
||||||
mcpInstaller?: McpInstallService,
|
mcpInstaller?: McpInstallService,
|
||||||
apiKeyService?: ApiKeyService,
|
apiKeyService?: ApiKeyService,
|
||||||
mcpHealthDiagnosticsService?: McpHealthDiagnosticsService,
|
mcpHealthDiagnosticsService?: McpHealthDiagnosticsService,
|
||||||
|
skillsCatalogService?: SkillsCatalogService,
|
||||||
|
skillsMutationService?: SkillsMutationService,
|
||||||
|
skillsWatcherService?: SkillsWatcherService,
|
||||||
crossTeamService?: CrossTeamService
|
crossTeamService?: CrossTeamService
|
||||||
): void {
|
): void {
|
||||||
// Initialize domain handlers with registry
|
// Initialize domain handlers with registry
|
||||||
|
|
@ -186,6 +193,7 @@ export function initializeIpcHandlers(
|
||||||
apiKeyService,
|
apiKeyService,
|
||||||
mcpHealthDiagnosticsService
|
mcpHealthDiagnosticsService
|
||||||
);
|
);
|
||||||
|
initializeSkillsHandlers(skillsCatalogService, skillsMutationService, skillsWatcherService);
|
||||||
}
|
}
|
||||||
if (crossTeamService) {
|
if (crossTeamService) {
|
||||||
initializeCrossTeamHandlers(crossTeamService);
|
initializeCrossTeamHandlers(crossTeamService);
|
||||||
|
|
@ -229,6 +237,7 @@ export function initializeIpcHandlers(
|
||||||
}
|
}
|
||||||
if (extensionFacade) {
|
if (extensionFacade) {
|
||||||
registerExtensionHandlers(ipcMain);
|
registerExtensionHandlers(ipcMain);
|
||||||
|
registerSkillsHandlers(ipcMain);
|
||||||
}
|
}
|
||||||
if (crossTeamService) {
|
if (crossTeamService) {
|
||||||
registerCrossTeamHandlers(ipcMain);
|
registerCrossTeamHandlers(ipcMain);
|
||||||
|
|
@ -263,6 +272,7 @@ export function removeIpcHandlers(): void {
|
||||||
removeTerminalHandlers(ipcMain);
|
removeTerminalHandlers(ipcMain);
|
||||||
removeHttpServerHandlers(ipcMain);
|
removeHttpServerHandlers(ipcMain);
|
||||||
removeExtensionHandlers(ipcMain);
|
removeExtensionHandlers(ipcMain);
|
||||||
|
removeSkillsHandlers(ipcMain);
|
||||||
removeCrossTeamHandlers(ipcMain);
|
removeCrossTeamHandlers(ipcMain);
|
||||||
|
|
||||||
logger.info('All handlers removed');
|
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 { McpInstallService } from './install/McpInstallService';
|
||||||
export { ApiKeyService } from './apikeys/ApiKeyService';
|
export { ApiKeyService } from './apikeys/ApiKeyService';
|
||||||
export { GitHubStarsService } from './catalog/GitHubStarsService';
|
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 */
|
/** Fetch GitHub stars for MCP server repositories */
|
||||||
export const MCP_GITHUB_STARS = 'mcpRegistry:githubStars';
|
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
|
// API Keys Management Channels
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,16 @@ import {
|
||||||
MCP_REGISTRY_INSTALL_CUSTOM,
|
MCP_REGISTRY_INSTALL_CUSTOM,
|
||||||
MCP_REGISTRY_UNINSTALL,
|
MCP_REGISTRY_UNINSTALL,
|
||||||
MCP_GITHUB_STARS,
|
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_LIST,
|
||||||
API_KEYS_SAVE,
|
API_KEYS_SAVE,
|
||||||
API_KEYS_DELETE,
|
API_KEYS_DELETE,
|
||||||
|
|
@ -280,6 +290,13 @@ import type {
|
||||||
McpSearchResult,
|
McpSearchResult,
|
||||||
OperationResult,
|
OperationResult,
|
||||||
PluginInstallRequest,
|
PluginInstallRequest,
|
||||||
|
SkillCatalogItem,
|
||||||
|
SkillDeleteRequest,
|
||||||
|
SkillDetail,
|
||||||
|
SkillImportRequest,
|
||||||
|
SkillReviewPreview,
|
||||||
|
SkillUpsertRequest,
|
||||||
|
SkillWatcherEvent,
|
||||||
} from '@shared/types/extensions';
|
} from '@shared/types/extensions';
|
||||||
import type {
|
import type {
|
||||||
BinaryPreviewResult,
|
BinaryPreviewResult,
|
||||||
|
|
@ -1397,6 +1414,34 @@ const electronAPI: ElectronAPI = {
|
||||||
invokeIpcWithResult<Record<string, number>>(MCP_GITHUB_STARS, repositoryUrls),
|
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) =====
|
// ===== API Keys API (Electron-only) =====
|
||||||
apiKeys: {
|
apiKeys: {
|
||||||
list: () => invokeIpcWithResult<ApiKeyEntry[]>(API_KEYS_LIST),
|
list: () => invokeIpcWithResult<ApiKeyEntry[]>(API_KEYS_LIST),
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@
|
||||||
* Global catalog data comes from Zustand store.
|
* 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 { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
|
|
@ -17,53 +18,78 @@ import {
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} 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 { ApiKeysPanel } from './apikeys/ApiKeysPanel';
|
||||||
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
|
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
|
||||||
import { McpServersPanel } from './mcp/McpServersPanel';
|
import { McpServersPanel } from './mcp/McpServersPanel';
|
||||||
import { PluginsPanel } from './plugins/PluginsPanel';
|
import { PluginsPanel } from './plugins/PluginsPanel';
|
||||||
|
import { SkillsPanel } from './skills/SkillsPanel';
|
||||||
|
|
||||||
export const ExtensionStoreView = (): React.JSX.Element => {
|
export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
|
const tabId = useTabIdOptional();
|
||||||
const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog);
|
const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog);
|
||||||
const fetchApiKeys = useStore((s) => s.fetchApiKeys);
|
const fetchApiKeys = useStore((s) => s.fetchApiKeys);
|
||||||
|
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
||||||
const mcpBrowse = useStore((s) => s.mcpBrowse);
|
const mcpBrowse = useStore((s) => s.mcpBrowse);
|
||||||
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
|
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
|
||||||
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
|
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
|
||||||
const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading);
|
const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading);
|
||||||
|
const skillsLoading = useStore((s) => s.skillsLoading);
|
||||||
const cliStatus = useStore((s) => s.cliStatus);
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
const cliInstalled = cliStatus?.installed ?? true; // assume installed until checked
|
const cliInstalled = cliStatus?.installed ?? true; // assume installed until checked
|
||||||
const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing));
|
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 tabState = useExtensionsTabState();
|
||||||
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
|
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
|
// Fetch plugin catalog on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchPluginCatalog();
|
void fetchPluginCatalog(projectPath ?? undefined);
|
||||||
}, [fetchPluginCatalog]);
|
}, [fetchPluginCatalog, projectPath]);
|
||||||
|
|
||||||
// Fetch MCP installed state on mount
|
// Fetch MCP installed state on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void mcpFetchInstalled();
|
void mcpFetchInstalled(projectPath ?? undefined);
|
||||||
}, [mcpFetchInstalled]);
|
}, [mcpFetchInstalled, projectPath]);
|
||||||
|
|
||||||
// Fetch API keys on mount
|
// Fetch API keys on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchApiKeys();
|
void fetchApiKeys();
|
||||||
}, [fetchApiKeys]);
|
}, [fetchApiKeys]);
|
||||||
|
|
||||||
// Refresh all data (plugins + MCP browse + installed)
|
// Fetch Skills catalog on mount / project change
|
||||||
const handleRefresh = useCallback(() => {
|
useEffect(() => {
|
||||||
void fetchPluginCatalog(undefined, true);
|
void fetchSkillsCatalog(projectPath ?? undefined);
|
||||||
void mcpBrowse(); // re-fetch first page
|
}, [fetchSkillsCatalog, projectPath]);
|
||||||
void mcpFetchInstalled();
|
|
||||||
}, [fetchPluginCatalog, mcpBrowse, mcpFetchInstalled]);
|
|
||||||
|
|
||||||
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
|
// Browser mode guard
|
||||||
if (!api.plugins && !api.mcpRegistry) {
|
if (!api.plugins && !api.mcpRegistry && !api.skills) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -114,7 +140,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tabState.activeSubTab}
|
value={tabState.activeSubTab}
|
||||||
onValueChange={(v) =>
|
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">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
|
@ -127,6 +153,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
<Server className="size-3.5" />
|
<Server className="size-3.5" />
|
||||||
MCP Servers
|
MCP Servers
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="skills" className="gap-1.5">
|
||||||
|
<BookOpen className="size-3.5" />
|
||||||
|
Skills
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="api-keys" className="gap-1.5">
|
<TabsTrigger value="api-keys" className="gap-1.5">
|
||||||
<Key className="size-3.5" />
|
<Key className="size-3.5" />
|
||||||
API Keys
|
API Keys
|
||||||
|
|
@ -176,6 +206,19 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
<TabsContent value="api-keys">
|
<TabsContent value="api-keys">
|
||||||
<ApiKeysPanel />
|
<ApiKeysPanel />
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
|
|
||||||
{/* Custom MCP server dialog (lifted to store view level) */}
|
{/* 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,
|
PluginSortField,
|
||||||
} from '@shared/types/extensions';
|
} 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 {
|
interface PluginSortState {
|
||||||
field: PluginSortField;
|
field: PluginSortField;
|
||||||
|
|
@ -49,6 +50,12 @@ export function useExtensionsTabState() {
|
||||||
const [mcpSearchWarnings, setMcpSearchWarnings] = useState<string[]>([]);
|
const [mcpSearchWarnings, setMcpSearchWarnings] = useState<string[]>([]);
|
||||||
const [selectedMcpServerId, setSelectedMcpServerId] = useState<string | null>(null);
|
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 ──
|
// ── Debounced MCP search ──
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
|
@ -163,5 +170,14 @@ export function useExtensionsTabState() {
|
||||||
mcpSearchWarnings,
|
mcpSearchWarnings,
|
||||||
selectedMcpServerId,
|
selectedMcpServerId,
|
||||||
setSelectedMcpServerId,
|
setSelectedMcpServerId,
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
skillsSearchQuery,
|
||||||
|
setSkillsSearchQuery,
|
||||||
|
skillsInstalledOnly,
|
||||||
|
skillsSort,
|
||||||
|
setSkillsSort,
|
||||||
|
selectedSkillId,
|
||||||
|
setSelectedSkillId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ import type {
|
||||||
McpInstallRequest,
|
McpInstallRequest,
|
||||||
McpServerDiagnostic,
|
McpServerDiagnostic,
|
||||||
PluginInstallRequest,
|
PluginInstallRequest,
|
||||||
|
SkillCatalogItem,
|
||||||
|
SkillDeleteRequest,
|
||||||
|
SkillDetail,
|
||||||
|
SkillImportRequest,
|
||||||
|
SkillReviewPreview,
|
||||||
|
SkillUpsertRequest,
|
||||||
} from '@shared/types/extensions';
|
} from '@shared/types/extensions';
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
|
|
@ -59,6 +65,16 @@ export interface ExtensionsSlice {
|
||||||
apiKeySaving: boolean;
|
apiKeySaving: boolean;
|
||||||
apiKeyStorageStatus: ApiKeyStorageStatus | null;
|
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) ──
|
// ── GitHub Stars (supplementary) ──
|
||||||
mcpGitHubStars: Record<string, number>;
|
mcpGitHubStars: Record<string, number>;
|
||||||
|
|
||||||
|
|
@ -68,6 +84,13 @@ export interface ExtensionsSlice {
|
||||||
mcpBrowse: (cursor?: string) => Promise<void>;
|
mcpBrowse: (cursor?: string) => Promise<void>;
|
||||||
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
|
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
|
||||||
runMcpDiagnostics: () => 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 ──
|
// ── Mutation actions ──
|
||||||
installPlugin: (request: PluginInstallRequest) => Promise<void>;
|
installPlugin: (request: PluginInstallRequest) => Promise<void>;
|
||||||
|
|
@ -137,6 +160,15 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
apiKeySaving: false,
|
apiKeySaving: false,
|
||||||
apiKeyStorageStatus: null,
|
apiKeyStorageStatus: null,
|
||||||
|
|
||||||
|
skillsUserCatalog: [],
|
||||||
|
skillsProjectCatalogByProjectPath: {},
|
||||||
|
skillsLoading: false,
|
||||||
|
skillsError: null,
|
||||||
|
skillsDetailsById: {},
|
||||||
|
skillsDetailLoadingById: {},
|
||||||
|
skillsMutationLoading: false,
|
||||||
|
skillsMutationError: null,
|
||||||
|
|
||||||
mcpGitHubStars: {},
|
mcpGitHubStars: {},
|
||||||
|
|
||||||
// ── Plugin catalog fetch ──
|
// ── Plugin catalog fetch ──
|
||||||
|
|
@ -282,6 +314,156 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
await promise;
|
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 ──
|
// ── Plugin install ──
|
||||||
installPlugin: async (request: PluginInstallRequest) => {
|
installPlugin: async (request: PluginInstallRequest) => {
|
||||||
if (!api.plugins) return;
|
if (!api.plugins) return;
|
||||||
|
|
@ -595,6 +777,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
state.openTab({
|
state.openTab({
|
||||||
type: 'extensions',
|
type: 'extensions',
|
||||||
label: 'Extensions',
|
label: 'Extensions',
|
||||||
|
projectId: state.selectedProjectId ?? state.activeProjectId ?? undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
import type { CliArgsValidationResult } from '../utils/cliArgsParser';
|
import type { CliArgsValidationResult } from '../utils/cliArgsParser';
|
||||||
import type { CliInstallerAPI } from './cliInstaller';
|
import type { CliInstallerAPI } from './cliInstaller';
|
||||||
import type { EditorAPI, ProjectAPI } from './editor';
|
import type { EditorAPI, ProjectAPI } from './editor';
|
||||||
import type { McpCatalogAPI, PluginCatalogAPI, ApiKeysAPI } from './extensions';
|
import type { McpCatalogAPI, PluginCatalogAPI, ApiKeysAPI, SkillsCatalogAPI } from './extensions';
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
DetectedError,
|
DetectedError,
|
||||||
|
|
@ -814,6 +814,9 @@ export interface ElectronAPI {
|
||||||
// Extension Store — MCP Registry API (Electron-only, optional)
|
// Extension Store — MCP Registry API (Electron-only, optional)
|
||||||
mcpRegistry?: McpCatalogAPI;
|
mcpRegistry?: McpCatalogAPI;
|
||||||
|
|
||||||
|
// Extension Store — Skills Catalog API (Electron-only, optional)
|
||||||
|
skills?: SkillsCatalogAPI;
|
||||||
|
|
||||||
// Extension Store — API Keys Management (Electron-only, optional)
|
// Extension Store — API Keys Management (Electron-only, optional)
|
||||||
apiKeys?: ApiKeysAPI;
|
apiKeys?: ApiKeysAPI;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,15 @@ import type {
|
||||||
McpServerDiagnostic,
|
McpServerDiagnostic,
|
||||||
McpSearchResult,
|
McpSearchResult,
|
||||||
} from './mcp';
|
} from './mcp';
|
||||||
|
import type {
|
||||||
|
SkillCatalogItem,
|
||||||
|
SkillDeleteRequest,
|
||||||
|
SkillDetail,
|
||||||
|
SkillImportRequest,
|
||||||
|
SkillReviewPreview,
|
||||||
|
SkillUpsertRequest,
|
||||||
|
SkillWatcherEvent,
|
||||||
|
} from './skill';
|
||||||
|
|
||||||
// ── Plugin API ─────────────────────────────────────────────────────────────
|
// ── Plugin API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -50,6 +59,21 @@ export interface McpCatalogAPI {
|
||||||
githubStars: (repositoryUrls: string[]) => Promise<Record<string, number>>;
|
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 ──────────────────────────────────────────────────────────
|
// ── API Keys API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ApiKeysAPI {
|
export interface ApiKeysAPI {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,32 @@ export type {
|
||||||
McpToolDef,
|
McpToolDef,
|
||||||
} from './mcp';
|
} 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 {
|
export type {
|
||||||
ApiKeyEntry,
|
ApiKeyEntry,
|
||||||
ApiKeyLookupResult,
|
ApiKeyLookupResult,
|
||||||
|
|
@ -40,4 +66,4 @@ export type {
|
||||||
ApiKeyStorageStatus,
|
ApiKeyStorageStatus,
|
||||||
} from './apikey';
|
} 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(),
|
install: vi.fn(),
|
||||||
uninstall: 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');
|
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', () => {
|
it('activates existing extensions tab instead of creating new', () => {
|
||||||
store.getState().openExtensionsTab();
|
store.getState().openExtensionsTab();
|
||||||
const tabs1 = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
|
const tabs1 = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue