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:
iliya 2026-03-11 21:46:56 +02:00
parent 4fa739d887
commit 4b4dccd13d
39 changed files with 4062 additions and 18 deletions

View 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.

View file

@ -147,6 +147,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"yaml": "^2.8.2",
"yet-another-react-lightbox": "^3.29.1",
"zustand": "^4.5.0"
},

View file

@ -254,6 +254,9 @@ importers:
unified:
specifier: ^11.0.5
version: 11.0.5
yaml:
specifier: ^2.8.2
version: 2.8.2
yet-another-react-lightbox:
specifier: ^3.29.1
version: 3.29.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)

View file

@ -29,6 +29,7 @@ import { SchedulerService } from '@main/services/schedule/SchedulerService';
import {
CONTEXT_CHANGED,
SCHEDULE_CHANGE,
SKILLS_CHANGED,
SSH_STATUS,
TEAM_CHANGE,
TEAM_TOOL_APPROVAL_EVENT,
@ -84,6 +85,9 @@ import {
PluginCatalogService,
PluginInstallationStateService,
PluginInstallService,
SkillsCatalogService,
SkillsMutationService,
SkillsWatcherService,
} from './services/extensions';
import type { FileChangeEvent } from '@main/types';
@ -339,6 +343,7 @@ let cliInstallerService: CliInstallerService;
let ptyTerminalService: PtyTerminalService;
let httpServer: HttpServer;
let schedulerService: SchedulerService;
let skillsWatcherService: SkillsWatcherService | null = null;
// File watcher event cleanup functions
let fileChangeCleanup: (() => void) | null = null;
@ -713,6 +718,9 @@ function initializeServices(): void {
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
const mcpStateService = new McpInstallationStateService();
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(null);
const skillsCatalogService = new SkillsCatalogService();
const skillsMutationService = new SkillsMutationService();
skillsWatcherService = new SkillsWatcherService();
const extensionFacadeService = new ExtensionFacadeService(
pluginCatalogService,
pluginStateService,
@ -744,6 +752,12 @@ function initializeServices(): void {
}
});
skillsWatcherService.setEmitter((event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(SKILLS_CHANGED, event);
}
});
teamProvisioningService.setToolApprovalEventEmitter((event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(TEAM_TOOL_APPROVAL_EVENT, event);
@ -786,6 +800,9 @@ function initializeServices(): void {
mcpInstallService,
apiKeyService,
mcpHealthDiagnosticsService,
skillsCatalogService,
skillsMutationService,
skillsWatcherService,
crossTeamService
);
@ -900,6 +917,8 @@ function shutdownServices(): void {
void schedulerService.stop();
}
void skillsWatcherService?.stopAll();
// Kill all PTY processes
if (ptyTerminalService) {
ptyTerminalService.killAll();

View file

@ -66,6 +66,7 @@ import {
removeSessionHandlers,
} from './sessions';
import { initializeSshHandlers, registerSshHandlers, removeSshHandlers } from './ssh';
import { initializeSkillsHandlers, registerSkillsHandlers, removeSkillsHandlers } from './skills';
import {
initializeSubagentHandlers,
registerSubagentHandlers,
@ -109,6 +110,9 @@ import type { McpInstallService } from '../services/extensions/install/McpInstal
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService';
import type { SkillsCatalogService } from '../services/extensions/skills/SkillsCatalogService';
import type { SkillsMutationService } from '../services/extensions/skills/SkillsMutationService';
import type { SkillsWatcherService } from '../services/extensions/skills/SkillsWatcherService';
import type { SchedulerService } from '../services/schedule/SchedulerService';
/**
@ -143,6 +147,9 @@ export function initializeIpcHandlers(
mcpInstaller?: McpInstallService,
apiKeyService?: ApiKeyService,
mcpHealthDiagnosticsService?: McpHealthDiagnosticsService,
skillsCatalogService?: SkillsCatalogService,
skillsMutationService?: SkillsMutationService,
skillsWatcherService?: SkillsWatcherService,
crossTeamService?: CrossTeamService
): void {
// Initialize domain handlers with registry
@ -186,6 +193,7 @@ export function initializeIpcHandlers(
apiKeyService,
mcpHealthDiagnosticsService
);
initializeSkillsHandlers(skillsCatalogService, skillsMutationService, skillsWatcherService);
}
if (crossTeamService) {
initializeCrossTeamHandlers(crossTeamService);
@ -229,6 +237,7 @@ export function initializeIpcHandlers(
}
if (extensionFacade) {
registerExtensionHandlers(ipcMain);
registerSkillsHandlers(ipcMain);
}
if (crossTeamService) {
registerCrossTeamHandlers(ipcMain);
@ -263,6 +272,7 @@ export function removeIpcHandlers(): void {
removeTerminalHandlers(ipcMain);
removeHttpServerHandlers(ipcMain);
removeExtensionHandlers(ipcMain);
removeSkillsHandlers(ipcMain);
removeCrossTeamHandlers(ipcMain);
logger.info('All handlers removed');

202
src/main/ipc/skills.ts Normal file
View 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);
});
}

View file

@ -14,3 +14,13 @@ export { PluginInstallService } from './install/PluginInstallService';
export { McpInstallService } from './install/McpInstallService';
export { ApiKeyService } from './apikeys/ApiKeyService';
export { GitHubStarsService } from './catalog/GitHubStarsService';
export { SkillRootsResolver } from './skills/SkillRootsResolver';
export { SkillScanner } from './skills/SkillScanner';
export { SkillMetadataParser } from './skills/SkillMetadataParser';
export { SkillValidator } from './skills/SkillValidator';
export { SkillsCatalogService } from './skills/SkillsCatalogService';
export { SkillScaffoldService } from './skills/SkillScaffoldService';
export { SkillImportService } from './skills/SkillImportService';
export { SkillReviewService } from './skills/SkillReviewService';
export { SkillsMutationService } from './skills/SkillsMutationService';
export { SkillsWatcherService } from './skills/SkillsWatcherService';

View 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));
}
}

View 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';
}
}

View 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;
}
}
}

View 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;
}
}

View 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('/');
}
}

View 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;
}
}
}

View 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}`;
}
}

View 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}`)
);
}
}

View 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;
}
}

View 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,
});
}
}
}

View file

@ -615,6 +615,40 @@ export const MCP_REGISTRY_INSTALL_CUSTOM = 'mcpRegistry:installCustom';
/** Fetch GitHub stars for MCP server repositories */
export const MCP_GITHUB_STARS = 'mcpRegistry:githubStars';
// =============================================================================
// Extensions / Skills Channels
// =============================================================================
/** List discovered local skills */
export const SKILLS_LIST = 'skills:list';
/** Get full detail for a discovered skill */
export const SKILLS_GET_DETAIL = 'skills:getDetail';
/** Preview create/update changes for a skill */
export const SKILLS_PREVIEW_UPSERT = 'skills:previewUpsert';
/** Apply create/update changes for a skill */
export const SKILLS_APPLY_UPSERT = 'skills:applyUpsert';
/** Preview import changes for a skill folder */
export const SKILLS_PREVIEW_IMPORT = 'skills:previewImport';
/** Apply import for a skill folder */
export const SKILLS_APPLY_IMPORT = 'skills:applyImport';
/** Delete an existing skill */
export const SKILLS_DELETE = 'skills:delete';
/** Start focused watcher for active skill roots */
export const SKILLS_START_WATCHING = 'skills:startWatching';
/** Stop focused watcher for active skill roots */
export const SKILLS_STOP_WATCHING = 'skills:stopWatching';
/** Renderer event for focused skill root changes */
export const SKILLS_CHANGED = 'skills:changed';
// =============================================================================
// API Keys Management Channels
// =============================================================================

View file

@ -158,6 +158,16 @@ import {
MCP_REGISTRY_INSTALL_CUSTOM,
MCP_REGISTRY_UNINSTALL,
MCP_GITHUB_STARS,
SKILLS_APPLY_IMPORT,
SKILLS_APPLY_UPSERT,
SKILLS_CHANGED,
SKILLS_DELETE,
SKILLS_GET_DETAIL,
SKILLS_LIST,
SKILLS_PREVIEW_IMPORT,
SKILLS_PREVIEW_UPSERT,
SKILLS_START_WATCHING,
SKILLS_STOP_WATCHING,
API_KEYS_LIST,
API_KEYS_SAVE,
API_KEYS_DELETE,
@ -280,6 +290,13 @@ import type {
McpSearchResult,
OperationResult,
PluginInstallRequest,
SkillCatalogItem,
SkillDeleteRequest,
SkillDetail,
SkillImportRequest,
SkillReviewPreview,
SkillUpsertRequest,
SkillWatcherEvent,
} from '@shared/types/extensions';
import type {
BinaryPreviewResult,
@ -1397,6 +1414,34 @@ const electronAPI: ElectronAPI = {
invokeIpcWithResult<Record<string, number>>(MCP_GITHUB_STARS, repositoryUrls),
},
// ===== Skills Catalog API (Electron-only) =====
skills: {
list: (projectPath?: string) =>
invokeIpcWithResult<SkillCatalogItem[]>(SKILLS_LIST, projectPath),
getDetail: (skillId: string, projectPath?: string) =>
invokeIpcWithResult<SkillDetail | null>(SKILLS_GET_DETAIL, skillId, projectPath),
previewUpsert: (request: SkillUpsertRequest) =>
invokeIpcWithResult<SkillReviewPreview>(SKILLS_PREVIEW_UPSERT, request),
applyUpsert: (request: SkillUpsertRequest) =>
invokeIpcWithResult<SkillDetail | null>(SKILLS_APPLY_UPSERT, request),
previewImport: (request: SkillImportRequest) =>
invokeIpcWithResult<SkillReviewPreview>(SKILLS_PREVIEW_IMPORT, request),
applyImport: (request: SkillImportRequest) =>
invokeIpcWithResult<SkillDetail | null>(SKILLS_APPLY_IMPORT, request),
deleteSkill: (request: SkillDeleteRequest) => invokeIpcWithResult<void>(SKILLS_DELETE, request),
startWatching: (projectPath?: string) =>
invokeIpcWithResult<string>(SKILLS_START_WATCHING, projectPath),
stopWatching: (watchId: string) => invokeIpcWithResult<void>(SKILLS_STOP_WATCHING, watchId),
onChanged: (callback: (event: SkillWatcherEvent) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: SkillWatcherEvent): void =>
callback(data);
ipcRenderer.on(SKILLS_CHANGED, listener);
return (): void => {
ipcRenderer.removeListener(SKILLS_CHANGED, listener);
};
},
},
// ===== API Keys API (Electron-only) =====
apiKeys: {
list: () => invokeIpcWithResult<ApiKeyEntry[]>(API_KEYS_LIST),

View file

@ -4,10 +4,11 @@
* Global catalog data comes from Zustand store.
*/
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
import { useStore } from '@renderer/store';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
@ -17,53 +18,78 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { AlertTriangle, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
import { McpServersPanel } from './mcp/McpServersPanel';
import { PluginsPanel } from './plugins/PluginsPanel';
import { SkillsPanel } from './skills/SkillsPanel';
export const ExtensionStoreView = (): React.JSX.Element => {
const tabId = useTabIdOptional();
const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog);
const fetchApiKeys = useStore((s) => s.fetchApiKeys);
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
const mcpBrowse = useStore((s) => s.mcpBrowse);
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading);
const skillsLoading = useStore((s) => s.skillsLoading);
const cliStatus = useStore((s) => s.cliStatus);
const cliInstalled = cliStatus?.installed ?? true; // assume installed until checked
const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing));
const projects = useStore((s) => s.projects);
const extensionsTabProjectId = useStore((s) =>
tabId
? (s.paneLayout.panes.flatMap((pane) => pane.tabs).find((tab) => tab.id === tabId)
?.projectId ?? null)
: null
);
const tabState = useExtensionsTabState();
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
const projectPath = useMemo(
() => projects.find((project) => project.id === extensionsTabProjectId)?.path ?? null,
[extensionsTabProjectId, projects]
);
const projectLabel = useMemo(
() => projects.find((project) => project.id === extensionsTabProjectId)?.name ?? null,
[extensionsTabProjectId, projects]
);
// Fetch plugin catalog on mount
useEffect(() => {
void fetchPluginCatalog();
}, [fetchPluginCatalog]);
void fetchPluginCatalog(projectPath ?? undefined);
}, [fetchPluginCatalog, projectPath]);
// Fetch MCP installed state on mount
useEffect(() => {
void mcpFetchInstalled();
}, [mcpFetchInstalled]);
void mcpFetchInstalled(projectPath ?? undefined);
}, [mcpFetchInstalled, projectPath]);
// Fetch API keys on mount
useEffect(() => {
void fetchApiKeys();
}, [fetchApiKeys]);
// Refresh all data (plugins + MCP browse + installed)
const handleRefresh = useCallback(() => {
void fetchPluginCatalog(undefined, true);
void mcpBrowse(); // re-fetch first page
void mcpFetchInstalled();
}, [fetchPluginCatalog, mcpBrowse, mcpFetchInstalled]);
// Fetch Skills catalog on mount / project change
useEffect(() => {
void fetchSkillsCatalog(projectPath ?? undefined);
}, [fetchSkillsCatalog, projectPath]);
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading;
// Refresh all data (plugins + MCP browse + installed + skills)
const handleRefresh = useCallback(() => {
void fetchPluginCatalog(projectPath ?? undefined, true);
void mcpBrowse(); // re-fetch first page
void mcpFetchInstalled(projectPath ?? undefined);
void fetchSkillsCatalog(projectPath ?? undefined);
}, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]);
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
// Browser mode guard
if (!api.plugins && !api.mcpRegistry) {
if (!api.plugins && !api.mcpRegistry && !api.skills) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
@ -114,7 +140,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
<Tabs
value={tabState.activeSubTab}
onValueChange={(v) =>
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'api-keys')
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'skills' | 'api-keys')
}
>
<div className="mb-4 flex items-center justify-between">
@ -127,6 +153,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
<Server className="size-3.5" />
MCP Servers
</TabsTrigger>
<TabsTrigger value="skills" className="gap-1.5">
<BookOpen className="size-3.5" />
Skills
</TabsTrigger>
<TabsTrigger value="api-keys" className="gap-1.5">
<Key className="size-3.5" />
API Keys
@ -176,6 +206,19 @@ export const ExtensionStoreView = (): React.JSX.Element => {
<TabsContent value="api-keys">
<ApiKeysPanel />
</TabsContent>
<TabsContent value="skills">
<SkillsPanel
projectPath={projectPath}
projectLabel={projectLabel}
skillsSearchQuery={tabState.skillsSearchQuery}
setSkillsSearchQuery={tabState.setSkillsSearchQuery}
skillsSort={tabState.skillsSort}
setSkillsSort={tabState.setSkillsSort}
selectedSkillId={tabState.selectedSkillId}
setSelectedSkillId={tabState.setSelectedSkillId}
/>
</TabsContent>
</Tabs>
{/* Custom MCP server dialog (lifted to store view level) */}

View file

@ -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" />;
};

View 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>
);
};

View 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'}
/>
</>
);
};

View 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"
/>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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;
}

View file

@ -16,7 +16,8 @@ import type {
PluginSortField,
} from '@shared/types/extensions';
export type ExtensionsSubTab = 'plugins' | 'mcp-servers' | 'api-keys';
export type ExtensionsSubTab = 'plugins' | 'mcp-servers' | 'skills' | 'api-keys';
export type SkillsSortState = 'name-asc' | 'recent-desc';
interface PluginSortState {
field: PluginSortField;
@ -49,6 +50,12 @@ export function useExtensionsTabState() {
const [mcpSearchWarnings, setMcpSearchWarnings] = useState<string[]>([]);
const [selectedMcpServerId, setSelectedMcpServerId] = useState<string | null>(null);
// ── Skills browse ──
const [skillsSearchQuery, setSkillsSearchQuery] = useState('');
const [skillsInstalledOnly] = useState(false);
const [skillsSort, setSkillsSort] = useState<SkillsSortState>('name-asc');
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(null);
// ── Debounced MCP search ──
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -163,5 +170,14 @@ export function useExtensionsTabState() {
mcpSearchWarnings,
selectedMcpServerId,
setSelectedMcpServerId,
// Skills
skillsSearchQuery,
setSkillsSearchQuery,
skillsInstalledOnly,
skillsSort,
setSkillsSort,
selectedSkillId,
setSelectedSkillId,
};
}

View file

@ -19,6 +19,12 @@ import type {
McpInstallRequest,
McpServerDiagnostic,
PluginInstallRequest,
SkillCatalogItem,
SkillDeleteRequest,
SkillDetail,
SkillImportRequest,
SkillReviewPreview,
SkillUpsertRequest,
} from '@shared/types/extensions';
import type { StateCreator } from 'zustand';
@ -59,6 +65,16 @@ export interface ExtensionsSlice {
apiKeySaving: boolean;
apiKeyStorageStatus: ApiKeyStorageStatus | null;
// ── Skills catalog cache ──
skillsUserCatalog: SkillCatalogItem[];
skillsProjectCatalogByProjectPath: Record<string, SkillCatalogItem[]>;
skillsLoading: boolean;
skillsError: string | null;
skillsDetailsById: Record<string, SkillDetail | null | undefined>;
skillsDetailLoadingById: Record<string, boolean>;
skillsMutationLoading: boolean;
skillsMutationError: string | null;
// ── GitHub Stars (supplementary) ──
mcpGitHubStars: Record<string, number>;
@ -68,6 +84,13 @@ export interface ExtensionsSlice {
mcpBrowse: (cursor?: string) => Promise<void>;
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
runMcpDiagnostics: () => Promise<void>;
fetchSkillsCatalog: (projectPath?: string) => Promise<void>;
fetchSkillDetail: (skillId: string, projectPath?: string) => Promise<void>;
previewSkillUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
applySkillUpsert: (request: SkillUpsertRequest) => Promise<SkillDetail | null>;
previewSkillImport: (request: SkillImportRequest) => Promise<SkillReviewPreview>;
applySkillImport: (request: SkillImportRequest) => Promise<SkillDetail | null>;
deleteSkill: (request: SkillDeleteRequest) => Promise<void>;
// ── Mutation actions ──
installPlugin: (request: PluginInstallRequest) => Promise<void>;
@ -137,6 +160,15 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
apiKeySaving: false,
apiKeyStorageStatus: null,
skillsUserCatalog: [],
skillsProjectCatalogByProjectPath: {},
skillsLoading: false,
skillsError: null,
skillsDetailsById: {},
skillsDetailLoadingById: {},
skillsMutationLoading: false,
skillsMutationError: null,
mcpGitHubStars: {},
// ── Plugin catalog fetch ──
@ -282,6 +314,156 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
await promise;
},
fetchSkillsCatalog: async (projectPath?: string) => {
if (!api.skills) return;
set({ skillsLoading: true, skillsError: null });
try {
const skills = await api.skills.list(projectPath);
set((prev) => ({
skillsLoading: false,
skillsError: null,
skillsUserCatalog: skills.filter((skill) => skill.scope === 'user'),
skillsProjectCatalogByProjectPath: projectPath
? {
...prev.skillsProjectCatalogByProjectPath,
[projectPath]: skills.filter((skill) => skill.scope === 'project'),
}
: prev.skillsProjectCatalogByProjectPath,
}));
} catch (err) {
set({
skillsLoading: false,
skillsError: err instanceof Error ? err.message : 'Failed to load skills',
});
}
},
fetchSkillDetail: async (skillId: string, projectPath?: string) => {
if (!api.skills) return;
set((prev) => ({
skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: true },
}));
try {
const detail = await api.skills.getDetail(skillId, projectPath);
set((prev) => ({
skillsDetailsById: { ...prev.skillsDetailsById, [skillId]: detail },
skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: false },
}));
} catch {
set((prev) => ({
skillsDetailsById: { ...prev.skillsDetailsById, [skillId]: null },
skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: false },
}));
}
},
previewSkillUpsert: async (request: SkillUpsertRequest) => {
if (!api.skills) {
throw new Error('Skills API is not available');
}
set({ skillsMutationLoading: true, skillsMutationError: null });
try {
const preview = await api.skills.previewUpsert(request);
set({ skillsMutationLoading: false });
return preview;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to review skill changes';
set({ skillsMutationLoading: false, skillsMutationError: message });
throw err;
}
},
applySkillUpsert: async (request: SkillUpsertRequest) => {
if (!api.skills) {
throw new Error('Skills API is not available');
}
set({ skillsMutationLoading: true, skillsMutationError: null });
try {
const detail = await api.skills.applyUpsert(request);
await get().fetchSkillsCatalog(request.projectPath);
set((prev) => ({
skillsMutationLoading: false,
skillsDetailsById: detail?.item.id
? { ...prev.skillsDetailsById, [detail.item.id]: detail }
: prev.skillsDetailsById,
}));
return detail;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save skill';
set({ skillsMutationLoading: false, skillsMutationError: message });
throw err;
}
},
previewSkillImport: async (request: SkillImportRequest) => {
if (!api.skills) {
throw new Error('Skills API is not available');
}
set({ skillsMutationLoading: true, skillsMutationError: null });
try {
const preview = await api.skills.previewImport(request);
set({ skillsMutationLoading: false });
return preview;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to review import changes';
set({ skillsMutationLoading: false, skillsMutationError: message });
throw err;
}
},
applySkillImport: async (request: SkillImportRequest) => {
if (!api.skills) {
throw new Error('Skills API is not available');
}
set({ skillsMutationLoading: true, skillsMutationError: null });
try {
const detail = await api.skills.applyImport(request);
await get().fetchSkillsCatalog(request.projectPath);
set((prev) => ({
skillsMutationLoading: false,
skillsDetailsById: detail?.item.id
? { ...prev.skillsDetailsById, [detail.item.id]: detail }
: prev.skillsDetailsById,
}));
return detail;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to import skill';
set({ skillsMutationLoading: false, skillsMutationError: message });
throw err;
}
},
deleteSkill: async (request: SkillDeleteRequest) => {
if (!api.skills) {
throw new Error('Skills API is not available');
}
set({ skillsMutationLoading: true, skillsMutationError: null });
try {
await api.skills.deleteSkill(request);
await get().fetchSkillsCatalog(request.projectPath);
set((prev) => {
const nextDetails = { ...prev.skillsDetailsById };
delete nextDetails[request.skillId];
return {
skillsMutationLoading: false,
skillsDetailsById: nextDetails,
};
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete skill';
set({ skillsMutationLoading: false, skillsMutationError: message });
throw err;
}
},
// ── Plugin install ──
installPlugin: async (request: PluginInstallRequest) => {
if (!api.plugins) return;
@ -595,6 +777,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
state.openTab({
type: 'extensions',
label: 'Extensions',
projectId: state.selectedProjectId ?? state.activeProjectId ?? undefined,
});
},

View file

@ -10,7 +10,7 @@
import type { CliArgsValidationResult } from '../utils/cliArgsParser';
import type { CliInstallerAPI } from './cliInstaller';
import type { EditorAPI, ProjectAPI } from './editor';
import type { McpCatalogAPI, PluginCatalogAPI, ApiKeysAPI } from './extensions';
import type { McpCatalogAPI, PluginCatalogAPI, ApiKeysAPI, SkillsCatalogAPI } from './extensions';
import type {
AppConfig,
DetectedError,
@ -814,6 +814,9 @@ export interface ElectronAPI {
// Extension Store — MCP Registry API (Electron-only, optional)
mcpRegistry?: McpCatalogAPI;
// Extension Store — Skills Catalog API (Electron-only, optional)
skills?: SkillsCatalogAPI;
// Extension Store — API Keys Management (Electron-only, optional)
apiKeys?: ApiKeysAPI;
}

View file

@ -19,6 +19,15 @@ import type {
McpServerDiagnostic,
McpSearchResult,
} from './mcp';
import type {
SkillCatalogItem,
SkillDeleteRequest,
SkillDetail,
SkillImportRequest,
SkillReviewPreview,
SkillUpsertRequest,
SkillWatcherEvent,
} from './skill';
// ── Plugin API ─────────────────────────────────────────────────────────────
@ -50,6 +59,21 @@ export interface McpCatalogAPI {
githubStars: (repositoryUrls: string[]) => Promise<Record<string, number>>;
}
// ── Skills API ─────────────────────────────────────────────────────────────
export interface SkillsCatalogAPI {
list: (projectPath?: string) => Promise<SkillCatalogItem[]>;
getDetail: (skillId: string, projectPath?: string) => Promise<SkillDetail | null>;
previewUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
applyUpsert: (request: SkillUpsertRequest) => Promise<SkillDetail | null>;
previewImport: (request: SkillImportRequest) => Promise<SkillReviewPreview>;
applyImport: (request: SkillImportRequest) => Promise<SkillDetail | null>;
deleteSkill: (request: SkillDeleteRequest) => Promise<void>;
startWatching: (projectPath?: string) => Promise<string>;
stopWatching: (watchId: string) => Promise<void>;
onChanged: (callback: (event: SkillWatcherEvent) => void) => () => void;
}
// ── API Keys API ──────────────────────────────────────────────────────────
export interface ApiKeysAPI {

View file

@ -33,6 +33,32 @@ export type {
McpToolDef,
} from './mcp';
export type {
CreateSkillRequest,
DeleteSkillRequest,
SkillCatalogItem,
SkillDeleteRequest,
SkillDraft,
SkillDraftFile,
SkillDraftTemplateInput,
SkillDetail,
SkillDirectoryFlags,
SkillImportRequest,
SkillInvocationMode,
SkillIssueSeverity,
SkillRootKind,
SkillReviewAction,
SkillReviewFileChange,
SkillReviewPreview,
SkillSaveResult,
SkillScope,
SkillSourceType,
UpdateSkillRequest,
SkillUpsertRequest,
SkillValidationIssue,
SkillWatcherEvent,
} from './skill';
export type {
ApiKeyEntry,
ApiKeyLookupResult,
@ -40,4 +66,4 @@ export type {
ApiKeyStorageStatus,
} from './apikey';
export type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI } from './api';
export type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI, SkillsCatalogAPI } from './api';

View 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';
}

View 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'])
);
});
});

View 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');
});
});

View 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);
});
});

View 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');
});
});

View 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']);
});
});

View file

@ -24,6 +24,18 @@ vi.mock('../../../src/renderer/api', () => ({
install: vi.fn(),
uninstall: vi.fn(),
},
skills: {
list: vi.fn(),
getDetail: vi.fn(),
previewUpsert: vi.fn(),
applyUpsert: vi.fn(),
previewImport: vi.fn(),
applyImport: vi.fn(),
deleteSkill: vi.fn(),
startWatching: vi.fn(),
stopWatching: vi.fn(),
onChanged: vi.fn(),
},
},
}));
@ -183,6 +195,16 @@ describe('extensionsSlice', () => {
expect(extTab!.label).toBe('Extensions');
});
it('seeds projectId from activeProjectId when selectedProjectId is null', () => {
store.setState({ selectedProjectId: null, activeProjectId: 'project-active' });
store.getState().openExtensionsTab();
const tabs = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
const extTab = tabs.find((t) => t.type === 'extensions');
expect(extTab?.projectId).toBe('project-active');
});
it('activates existing extensions tab instead of creating new', () => {
store.getState().openExtensionsTab();
const tabs1 = store.getState().paneLayout.panes.flatMap((p) => p.tabs);