feat: add Radix UI Alert Dialog component and enhance SkillImportService

- Integrated the @radix-ui/react-alert-dialog package for improved alert dialog functionality.
- Updated SkillImportService to include a new inspectSourceDir method for enhanced file inspection and warning generation during skill imports.
- Refactored existing methods to streamline file reading and directory walking processes, improving overall performance and error handling.
- Added new SkillPlanService to manage skill upsert plans, enhancing the skills mutation workflow.
- Updated UI components to support new features and improve user experience in the skills management interface.
This commit is contained in:
iliya 2026-03-12 11:53:40 +02:00
parent e440042c2b
commit d53999ba45
27 changed files with 2453 additions and 428 deletions

View file

@ -96,6 +96,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",

View file

@ -101,6 +101,9 @@ importers:
'@fastify/static':
specifier: ^9.0.0
version: 9.0.0
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.15(@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)
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@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)
@ -1424,6 +1427,19 @@ packages:
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-alert-dialog@1.1.15':
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
@ -7995,6 +8011,20 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-alert-dialog@1.1.15(@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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-dialog': 1.1.15(@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)
'@radix-ui/react-primitive': 2.1.3(@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)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-arrow@1.1.7(@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)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@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

@ -21,6 +21,7 @@ export { SkillValidator } from './skills/SkillValidator';
export { SkillsCatalogService } from './skills/SkillsCatalogService';
export { SkillScaffoldService } from './skills/SkillScaffoldService';
export { SkillImportService } from './skills/SkillImportService';
export { SkillPlanService } from './skills/SkillPlanService';
export { SkillReviewService } from './skills/SkillReviewService';
export { SkillsMutationService } from './skills/SkillsMutationService';
export { SkillsWatcherService } from './skills/SkillsWatcherService';

View file

@ -13,6 +13,15 @@ export interface ImportedSkillSourceFile {
isBinary: boolean;
}
export interface SkillImportInspection {
files: ImportedSkillSourceFile[];
warnings: string[];
hiddenEntriesSkipped: number;
}
const MAX_IMPORT_FILE_COUNT = 200;
const MAX_IMPORT_TOTAL_BYTES = 10 * 1024 * 1024;
export class SkillImportService {
constructor(private readonly scanner = new SkillScanner()) {}
@ -36,11 +45,11 @@ export class SkillImportService {
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, '/');
async inspectSourceDir(sourceDir: string): Promise<SkillImportInspection> {
const normalizedSourceDir = await this.validateSourceDir(sourceDir);
const walked = await this.walkDirectory(normalizedSourceDir);
const files = await Promise.all(
walked.files.map(async ({ absolutePath, relativePath }) => {
const binary = await isBinaryFile(absolutePath);
return {
relativePath,
@ -50,6 +59,31 @@ export class SkillImportService {
};
})
);
const warnings: string[] = [];
if (walked.hiddenEntriesSkipped > 0) {
warnings.push('Hidden files and folders were skipped during import.');
}
if (files.some((file) => file.isBinary)) {
warnings.push('This import includes binary files. Binary files will be copied as-is.');
}
if (
files.some(
(file) => file.relativePath === 'scripts' || file.relativePath.startsWith('scripts/')
)
) {
warnings.push('This import includes scripts. Review them carefully before importing.');
}
return {
files,
warnings,
hiddenEntriesSkipped: walked.hiddenEntriesSkipped,
};
}
async readSourceFiles(sourceDir: string): Promise<ImportedSkillSourceFile[]> {
return (await this.inspectSourceDir(sourceDir)).files;
}
async writeImportedFiles(
@ -67,17 +101,57 @@ export class SkillImportService {
}
}
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);
private async walkDirectory(
rootDir: string
): Promise<{
files: Array<{ absolutePath: string; relativePath: string }>;
hiddenEntriesSkipped: number;
}> {
const allFiles: Array<{ absolutePath: string; relativePath: string }> = [];
let hiddenEntriesSkipped = 0;
let totalBytes = 0;
const visit = async (currentDir: string): Promise<void> => {
const dirEntries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of dirEntries) {
if (entry.name.startsWith('.')) {
hiddenEntriesSkipped += 1;
continue;
}
return [fullPath];
})
);
return results.flat().sort((a, b) => a.localeCompare(b));
const fullPath = path.join(currentDir, entry.name);
if (entry.isSymbolicLink()) {
throw new Error('Import source cannot contain symbolic links');
}
if (entry.isDirectory()) {
await visit(fullPath);
continue;
}
const stat = await fs.stat(fullPath);
totalBytes += stat.size;
if (allFiles.length + 1 > MAX_IMPORT_FILE_COUNT) {
throw new Error(`Import source has too many files (max ${MAX_IMPORT_FILE_COUNT})`);
}
if (totalBytes > MAX_IMPORT_TOTAL_BYTES) {
throw new Error(
`Import source is too large (max ${Math.floor(MAX_IMPORT_TOTAL_BYTES / (1024 * 1024))} MB)`
);
}
allFiles.push({
absolutePath: fullPath,
relativePath: path.relative(rootDir, fullPath).replace(/\\/g, '/'),
});
}
};
await visit(rootDir);
return {
files: allFiles.sort((a, b) => a.relativePath.localeCompare(b.relativePath)),
hiddenEntriesSkipped,
};
}
}

View file

@ -0,0 +1,412 @@
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import type {
SkillDraftFile,
SkillReviewFileChange,
SkillReviewPreview,
SkillReviewSummary,
} from '@shared/types/extensions';
import type { ImportedSkillSourceFile } from './SkillImportService';
import { SkillScanner } from './SkillScanner';
type SkillPlanInputFile =
| { relativePath: string; isBinary: false; content: string }
| { relativePath: string; isBinary: true; sourceAbsolutePath: string };
interface ManagedCurrentFile {
relativePath: string;
absolutePath: string;
}
interface SkillExecutionChange extends SkillReviewFileChange {
sourceAbsolutePath?: string;
}
export interface SkillExecutionPlan {
preview: SkillReviewPreview;
changes: SkillExecutionChange[];
}
const MANAGED_SUBDIRECTORIES = ['scripts', 'references', 'assets'] as const;
export class SkillPlanService {
constructor(private readonly scanner = new SkillScanner()) {}
async buildUpsertPlan(
targetSkillDir: string,
files: SkillDraftFile[]
): Promise<SkillExecutionPlan> {
const desiredFiles: SkillPlanInputFile[] = files.map((file) => ({
relativePath: file.relativePath,
isBinary: false,
content: file.content,
}));
return this.buildPlan(targetSkillDir, desiredFiles, 'upsert');
}
async buildImportPlan(
targetSkillDir: string,
files: ImportedSkillSourceFile[]
): Promise<SkillExecutionPlan> {
const desiredFiles: SkillPlanInputFile[] = files.map((file) =>
file.isBinary
? {
relativePath: file.relativePath,
isBinary: true,
sourceAbsolutePath: file.absolutePath,
}
: {
relativePath: file.relativePath,
isBinary: false,
content: file.content ?? '',
}
);
return this.buildPlan(targetSkillDir, desiredFiles, 'import');
}
async applyPlan(plan: SkillExecutionPlan): Promise<void> {
const backupRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-plan-backup-'));
const createdPaths: string[] = [];
const backups: Array<{ absolutePath: string; backupPath: string }> = [];
try {
for (const [index, change] of plan.changes.entries()) {
if (change.action !== 'create' && (await this.pathExists(change.absolutePath))) {
const backupPath = path.join(backupRoot, String(index));
await fs.mkdir(path.dirname(backupPath), { recursive: true });
await fs.copyFile(change.absolutePath, backupPath);
backups.push({ absolutePath: change.absolutePath, backupPath });
}
if (change.action === 'delete') {
await fs.rm(change.absolutePath, { force: true });
await this.cleanupManagedParents(
path.dirname(change.absolutePath),
plan.preview.targetSkillDir
);
continue;
}
await fs.mkdir(path.dirname(change.absolutePath), { recursive: true });
if (change.isBinary) {
if (!change.sourceAbsolutePath) {
throw new Error(`Missing binary source for ${change.relativePath}`);
}
await fs.copyFile(change.sourceAbsolutePath, change.absolutePath);
} else {
await fs.writeFile(change.absolutePath, change.newContent ?? '', 'utf8');
}
if (change.action === 'create') {
createdPaths.push(change.absolutePath);
}
}
await this.cleanupManagedDirectories(plan.preview.targetSkillDir);
} catch (error) {
await Promise.all(
createdPaths
.slice()
.reverse()
.map(async (absolutePath) => {
await fs.rm(absolutePath, { force: true });
await this.cleanupManagedParents(
path.dirname(absolutePath),
plan.preview.targetSkillDir
);
})
);
await Promise.all(
backups
.slice()
.reverse()
.map(async ({ absolutePath, backupPath }) => {
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.copyFile(backupPath, absolutePath);
})
);
throw error;
} finally {
await fs.rm(backupRoot, { recursive: true, force: true });
}
}
private async buildPlan(
targetSkillDir: string,
desiredFiles: SkillPlanInputFile[],
mode: 'upsert' | 'import'
): Promise<SkillExecutionPlan> {
const normalizedDesired = this.normalizeDesiredFiles(desiredFiles);
const [currentManagedFiles, allExistingFiles] = await Promise.all([
this.readCurrentManagedFiles(targetSkillDir),
this.listAllRelativeFiles(targetSkillDir),
]);
const changesByRelativePath = new Map<string, SkillExecutionChange>();
await Promise.all(
normalizedDesired.map(async (file) => {
const absolutePath = path.join(targetSkillDir, file.relativePath);
const existingTextContent = file.isBinary
? null
: await this.readUtf8IfExists(absolutePath);
const action = (await this.pathExists(absolutePath)) ? 'update' : 'create';
changesByRelativePath.set(file.relativePath, {
relativePath: file.relativePath,
absolutePath,
action,
oldContent: existingTextContent,
newContent: file.isBinary ? null : file.content,
isBinary: file.isBinary,
sourceAbsolutePath: file.isBinary ? file.sourceAbsolutePath : undefined,
});
})
);
for (const currentFile of currentManagedFiles.values()) {
if (changesByRelativePath.has(currentFile.relativePath)) {
continue;
}
const existingTextContent = await this.readUtf8IfExists(currentFile.absolutePath);
changesByRelativePath.set(currentFile.relativePath, {
relativePath: currentFile.relativePath,
absolutePath: currentFile.absolutePath,
action: 'delete',
oldContent: existingTextContent,
newContent: null,
isBinary: false,
});
}
const changes = [...changesByRelativePath.values()].sort((a, b) =>
a.relativePath.localeCompare(b.relativePath)
);
const warnings = this.buildWarnings({
changes,
currentManagedFiles,
allExistingFiles,
desiredFiles: new Set(normalizedDesired.map((file) => file.relativePath)),
mode,
});
const summary = changes.reduce<SkillReviewSummary>(
(acc, change) => {
acc[`${change.action}d` as 'created' | 'updated' | 'deleted'] += 1;
if (change.isBinary) {
acc.binary += 1;
}
return acc;
},
{ created: 0, updated: 0, deleted: 0, binary: 0 }
);
const preview: SkillReviewPreview = {
planId: this.buildPlanId(targetSkillDir, changes, warnings),
targetSkillDir,
changes: changes.map(({ sourceAbsolutePath: _sourceAbsolutePath, ...change }) => change),
warnings,
summary,
};
return { preview, changes };
}
private normalizeDesiredFiles(files: SkillPlanInputFile[]): SkillPlanInputFile[] {
const map = new Map<string, SkillPlanInputFile>();
for (const file of files) {
const normalizedPath = path.normalize(file.relativePath).replace(/\\/g, '/');
map.set(normalizedPath, { ...file, relativePath: normalizedPath });
}
return [...map.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
}
private async readCurrentManagedFiles(
targetSkillDir: string
): Promise<Map<string, ManagedCurrentFile>> {
const files = new Map<string, ManagedCurrentFile>();
const detectedSkillFile = await this.scanner.detectSkillFile(targetSkillDir);
if (detectedSkillFile) {
files.set(path.basename(detectedSkillFile), {
relativePath: path.basename(detectedSkillFile),
absolutePath: detectedSkillFile,
});
}
for (const directory of MANAGED_SUBDIRECTORIES) {
const fullDirectoryPath = path.join(targetSkillDir, directory);
const relativeFiles = await this.listAllRelativeFiles(fullDirectoryPath);
for (const relativePath of relativeFiles) {
const managedRelativePath = `${directory}/${relativePath}`;
files.set(managedRelativePath, {
relativePath: managedRelativePath,
absolutePath: path.join(fullDirectoryPath, relativePath),
});
}
}
return files;
}
private async listAllRelativeFiles(rootDir: string): Promise<string[]> {
try {
const rootStat = await fs.stat(rootDir);
if (!rootStat.isDirectory()) {
return [];
}
} catch {
return [];
}
const dirEntries = await fs.readdir(rootDir, { withFileTypes: true });
const entries = await Promise.all(
dirEntries.map(async (entry) => {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
const children = await this.listAllRelativeFiles(fullPath);
return children.map((child) => path.join(entry.name, child).replace(/\\/g, '/'));
}
return [entry.name];
})
);
return entries.flat().sort((a, b) => a.localeCompare(b));
}
private buildWarnings({
changes,
currentManagedFiles,
allExistingFiles,
desiredFiles,
mode,
}: {
changes: SkillExecutionChange[];
currentManagedFiles: Map<string, ManagedCurrentFile>;
allExistingFiles: string[];
desiredFiles: Set<string>;
mode: 'upsert' | 'import';
}): string[] {
const warnings: string[] = [];
const deleteCount = changes.filter((change) => change.action === 'delete').length;
const updateCount = changes.filter((change) => change.action === 'update').length;
const binaryCount = changes.filter((change) => change.isBinary).length;
if (deleteCount > 0) {
warnings.push(
deleteCount === 1
? '1 managed file will be removed to match this reviewed plan.'
: `${deleteCount} managed files will be removed to match this reviewed plan.`
);
}
if (updateCount > 0) {
warnings.push(
updateCount === 1
? '1 existing file will be overwritten.'
: `${updateCount} existing files will be overwritten.`
);
}
if (binaryCount > 0) {
warnings.push(
binaryCount === 1
? '1 binary file will be copied as-is.'
: `${binaryCount} binary files will be copied as-is.`
);
}
const managedPaths = new Set(currentManagedFiles.keys());
const unmanagedFiles = allExistingFiles.filter(
(relativePath) => !managedPaths.has(relativePath) && !desiredFiles.has(relativePath)
);
if (unmanagedFiles.length > 0) {
warnings.push(
mode === 'import'
? 'Existing files outside the imported plan will be kept as-is.'
: 'Existing files outside the managed skill set will be kept as-is.'
);
}
return warnings;
}
private buildPlanId(
targetSkillDir: string,
changes: SkillExecutionChange[],
warnings: string[]
): string {
const hash = createHash('sha256');
hash.update(targetSkillDir);
hash.update('\n');
for (const change of changes) {
hash.update(
JSON.stringify({
relativePath: change.relativePath,
action: change.action,
oldContent: change.oldContent,
newContent: change.newContent,
isBinary: change.isBinary,
sourceAbsolutePath: change.sourceAbsolutePath ?? null,
})
);
hash.update('\n');
}
for (const warning of warnings) {
hash.update(warning);
hash.update('\n');
}
return hash.digest('hex');
}
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;
}
return null;
}
}
private async pathExists(targetPath: string): Promise<boolean> {
try {
await fs.stat(targetPath);
return true;
} catch {
return false;
}
}
private async cleanupManagedDirectories(targetSkillDir: string): Promise<void> {
await Promise.all(
MANAGED_SUBDIRECTORIES.map((directory) =>
this.cleanupManagedParents(path.join(targetSkillDir, directory), targetSkillDir)
)
);
}
private async cleanupManagedParents(currentDir: string, targetSkillDir: string): Promise<void> {
let nextDir = currentDir;
while (nextDir.startsWith(targetSkillDir) && nextDir !== targetSkillDir) {
try {
const entries = await fs.readdir(nextDir);
if (entries.length > 0) {
return;
}
await fs.rmdir(nextDir);
} catch {
return;
}
nextDir = path.dirname(nextDir);
}
}
}

View file

@ -13,7 +13,7 @@ import { shell } from 'electron';
import { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation';
import { SkillImportService } from './SkillImportService';
import { SkillReviewService } from './SkillReviewService';
import { SkillPlanService } from './SkillPlanService';
import { SkillScaffoldService } from './SkillScaffoldService';
import { SkillRootsResolver } from './SkillRootsResolver';
import { SkillsCatalogService } from './SkillsCatalogService';
@ -24,7 +24,7 @@ export class SkillsMutationService {
private readonly catalogService = new SkillsCatalogService(),
private readonly scaffoldService = new SkillScaffoldService(rootsResolver),
private readonly importService = new SkillImportService(),
private readonly reviewService = new SkillReviewService()
private readonly planService = new SkillPlanService()
) {}
async previewUpsert(request: SkillUpsertRequest): Promise<SkillReviewPreview> {
@ -36,15 +36,15 @@ export class SkillsMutationService {
request.existingSkillId
);
const files = this.scaffoldService.normalizeDraftFiles(request.files);
const changes = await this.reviewService.buildTextChanges(targetSkillDir, files);
return {
targetSkillDir,
changes,
warnings: [],
};
const plan = await this.planService.buildUpsertPlan(targetSkillDir, files);
return plan.preview;
}
async applyUpsert(request: SkillUpsertRequest): Promise<SkillDetail | null> {
if (!request.reviewPlanId) {
throw new Error('Review the skill changes before saving.');
}
const targetSkillDir = await this.scaffoldService.resolveUpsertTarget(
request.scope,
request.rootKind,
@ -53,30 +53,33 @@ export class SkillsMutationService {
request.existingSkillId
);
const files = this.scaffoldService.normalizeDraftFiles(request.files);
await this.scaffoldService.writeTextFiles(targetSkillDir, files);
const plan = await this.planService.buildUpsertPlan(targetSkillDir, files);
this.assertReviewedPlanMatches(request.reviewPlanId, plan.preview.planId);
await this.planService.applyPlan(plan);
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.']
: [];
const inspection = await this.importService.inspectSourceDir(sourceDir);
const plan = await this.planService.buildImportPlan(targetSkillDir, inspection.files);
return {
targetSkillDir,
changes,
warnings,
...plan.preview,
warnings: [...new Set([...inspection.warnings, ...plan.preview.warnings])],
};
}
async applyImport(request: SkillImportRequest): Promise<SkillDetail | null> {
if (!request.reviewPlanId) {
throw new Error('Review the import changes before saving.');
}
const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request);
const sourceFiles = await this.importService.readSourceFiles(sourceDir);
await this.importService.writeImportedFiles(targetSkillDir, sourceFiles);
const inspection = await this.importService.inspectSourceDir(sourceDir);
const plan = await this.planService.buildImportPlan(targetSkillDir, inspection.files);
this.assertReviewedPlanMatches(request.reviewPlanId, plan.preview.planId);
await this.planService.applyPlan(plan);
return this.catalogService.getDetail(targetSkillDir, request.projectPath);
}
@ -133,4 +136,12 @@ export class SkillsMutationService {
}
return normalizedSkillDir;
}
private assertReviewedPlanMatches(reviewPlanId: string, currentPlanId: string): void {
if (reviewPlanId !== currentPlanId) {
throw new Error(
'The skill files changed after review. Review the latest changes and try again.'
);
}
}
}

View file

@ -11,7 +11,7 @@ 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';
import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
import {
Tooltip,
TooltipContent,
@ -21,6 +21,7 @@ import {
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
import { McpServersPanel } from './mcp/McpServersPanel';
import { PluginsPanel } from './plugins/PluginsPanel';
@ -57,6 +58,39 @@ export const ExtensionStoreView = (): React.JSX.Element => {
() => projects.find((project) => project.id === extensionsTabProjectId)?.name ?? null,
[extensionsTabProjectId, projects]
);
const subTabs = useMemo(
() => [
{
value: 'plugins' as const,
label: 'Plugins',
icon: Puzzle,
description:
'Small add-ons for Claude. They give the app extra features and integrations you can install when you need them.',
},
{
value: 'mcp-servers' as const,
label: 'MCP Servers',
icon: Server,
description:
'Connections to outside tools and apps. They let Claude read data or do actions beyond this app.',
},
{
value: 'skills' as const,
label: 'Skills',
icon: BookOpen,
description:
'Ready-made instructions for common jobs. They help Claude do specific tasks better and more consistently.',
},
{
value: 'api-keys' as const,
label: 'API Keys',
icon: Key,
description:
'Secret keys for online services. Add them here so plugins, servers, and integrations can connect and work.',
},
],
[]
);
// Fetch plugin catalog on mount
useEffect(() => {
@ -102,15 +136,15 @@ export const ExtensionStoreView = (): React.JSX.Element => {
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<div className="flex items-center gap-3">
<Puzzle className="size-5 text-text-muted" />
<h1 className="text-lg font-semibold text-text">Extensions</h1>
</div>
<TooltipProvider>
<TooltipProvider>
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Puzzle className="size-5 text-text-muted" />
<h1 className="text-lg font-semibold text-text">Extensions</h1>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleRefresh} disabled={isRefreshing}>
@ -119,116 +153,109 @@ export const ExtensionStoreView = (): React.JSX.Element => {
</TooltipTrigger>
<TooltipContent>Refresh catalog</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Sub-tabs */}
<div className="px-6 py-4">
{/* CLI not installed warning */}
{!cliInstalled && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-400">
<AlertTriangle className="size-4 shrink-0" />
Claude CLI is required to install or uninstall extensions. Install it from Settings.
</div>
)}
{/* Active sessions warning */}
{hasOngoingSessions && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-400">
<Info className="size-4 shrink-0" />
Running sessions won&apos;t pick up extension changes until restarted.
</div>
)}
<Tabs
value={tabState.activeSubTab}
onValueChange={(v) =>
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'skills' | 'api-keys')
}
>
<div className="mb-4 flex items-center justify-between">
<TabsList>
<TabsTrigger value="plugins" className="gap-1.5">
<Puzzle className="size-3.5" />
Plugins
</TabsTrigger>
<TabsTrigger value="mcp-servers" className="gap-1.5">
<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
</TabsTrigger>
</TabsList>
{tabState.activeSubTab === 'mcp-servers' && (
<Button
variant="outline"
size="sm"
onClick={() => setCustomMcpDialogOpen(true)}
className="whitespace-nowrap"
>
<Plus className="mr-1 size-3.5" />
Add Custom
</Button>
)}
</div>
{/* Sub-tabs */}
<div className="px-6 py-4">
{/* CLI not installed warning */}
{!cliInstalled && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-400">
<AlertTriangle className="size-4 shrink-0" />
Claude CLI is required to install or uninstall extensions. Install it from Settings.
</div>
)}
{/* Active sessions warning */}
{hasOngoingSessions && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-400">
<Info className="size-4 shrink-0" />
Running sessions won&apos;t pick up extension changes until restarted.
</div>
)}
<Tabs
value={tabState.activeSubTab}
onValueChange={(v) =>
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'skills' | 'api-keys')
}
>
<div className="-mx-6 flex items-end justify-between border-b border-border px-6">
<TabsList className="gap-1 rounded-b-none">
{subTabs.map((subTab) => (
<ExtensionsSubTabTrigger
key={subTab.value}
value={subTab.value}
label={subTab.label}
icon={subTab.icon}
description={subTab.description}
/>
))}
</TabsList>
{tabState.activeSubTab === 'mcp-servers' && (
<Button
variant="outline"
size="sm"
onClick={() => setCustomMcpDialogOpen(true)}
className="whitespace-nowrap"
>
<Plus className="mr-1 size-3.5" />
Add Custom
</Button>
)}
</div>
<TabsContent value="plugins">
<PluginsPanel
pluginFilters={tabState.pluginFilters}
pluginSort={tabState.pluginSort}
selectedPluginId={tabState.selectedPluginId}
updatePluginSearch={tabState.updatePluginSearch}
toggleCategory={tabState.toggleCategory}
toggleCapability={tabState.toggleCapability}
toggleInstalledOnly={tabState.toggleInstalledOnly}
setSelectedPluginId={tabState.setSelectedPluginId}
clearFilters={tabState.clearFilters}
hasActiveFilters={tabState.hasActiveFilters}
setPluginSort={tabState.setPluginSort}
/>
</TabsContent>
<TabsContent value="plugins" className="mt-0 pt-4">
<PluginsPanel
pluginFilters={tabState.pluginFilters}
pluginSort={tabState.pluginSort}
selectedPluginId={tabState.selectedPluginId}
updatePluginSearch={tabState.updatePluginSearch}
toggleCategory={tabState.toggleCategory}
toggleCapability={tabState.toggleCapability}
toggleInstalledOnly={tabState.toggleInstalledOnly}
setSelectedPluginId={tabState.setSelectedPluginId}
clearFilters={tabState.clearFilters}
hasActiveFilters={tabState.hasActiveFilters}
setPluginSort={tabState.setPluginSort}
/>
</TabsContent>
<TabsContent value="mcp-servers">
<McpServersPanel
mcpSearchQuery={tabState.mcpSearchQuery}
mcpSearch={tabState.mcpSearch}
mcpSearchResults={tabState.mcpSearchResults}
mcpSearchLoading={tabState.mcpSearchLoading}
mcpSearchWarnings={tabState.mcpSearchWarnings}
selectedMcpServerId={tabState.selectedMcpServerId}
setSelectedMcpServerId={tabState.setSelectedMcpServerId}
/>
</TabsContent>
<TabsContent value="mcp-servers" className="mt-0 pt-4">
<McpServersPanel
mcpSearchQuery={tabState.mcpSearchQuery}
mcpSearch={tabState.mcpSearch}
mcpSearchResults={tabState.mcpSearchResults}
mcpSearchLoading={tabState.mcpSearchLoading}
mcpSearchWarnings={tabState.mcpSearchWarnings}
selectedMcpServerId={tabState.selectedMcpServerId}
setSelectedMcpServerId={tabState.setSelectedMcpServerId}
/>
</TabsContent>
<TabsContent value="api-keys">
<ApiKeysPanel />
</TabsContent>
<TabsContent value="api-keys" className="mt-0 pt-4">
<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>
<TabsContent value="skills" className="mt-0 pt-4">
<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) */}
<CustomMcpServerDialog
open={customMcpDialogOpen}
onClose={() => setCustomMcpDialogOpen(false)}
/>
{/* Custom MCP server dialog (lifted to store view level) */}
<CustomMcpServerDialog
open={customMcpDialogOpen}
onClose={() => setCustomMcpDialogOpen(false)}
/>
</div>
</div>
</div>
</div>
</TooltipProvider>
);
};

View file

@ -0,0 +1,53 @@
import type { LucideIcon } from 'lucide-react';
import { TabsTrigger } from '@renderer/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Info } from 'lucide-react';
interface ExtensionsSubTabTriggerProps {
value: 'plugins' | 'mcp-servers' | 'skills' | 'api-keys';
label: string;
description: string;
icon: LucideIcon;
}
export const ExtensionsSubTabTrigger = ({
value,
label,
description,
icon: Icon,
}: ExtensionsSubTabTriggerProps): React.JSX.Element => {
return (
<TabsTrigger
value={value}
className="relative gap-1.5 rounded-b-none pr-7 data-[state=active]:z-10 data-[state=active]:-mb-px data-[state=active]:bg-[var(--color-surface)] data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:-bottom-px data-[state=active]:after:left-0 data-[state=active]:after:right-0 data-[state=active]:after:h-1 data-[state=active]:after:bg-[var(--color-surface)] data-[state=active]:after:content-['']"
>
<Icon className="size-3.5" />
{label}
<Tooltip>
<TooltipTrigger asChild>
<span
role="button"
tabIndex={0}
aria-label={`What is ${label}?`}
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation();
}
}}
className="size-4.5 absolute right-2 top-1 z-10 inline-flex items-center justify-center rounded-full text-text-muted transition-colors hover:bg-[var(--color-surface)] hover:text-text"
>
<Info className="size-3" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-64 text-pretty text-xs leading-relaxed">
{description}
</TooltipContent>
</Tooltip>
</TabsTrigger>
);
};

View file

@ -44,10 +44,10 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
onClick(plugin.pluginId);
}
}}
className={`hover:bg-surface-raised/45 relative flex w-full cursor-pointer flex-col gap-3 rounded-xl border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
baseStriped ? 'bg-surface-raised/10' : 'bg-transparent'
} ${smStriped ? 'sm:bg-surface-raised/10' : 'sm:bg-transparent'} ${
xlStriped ? 'xl:bg-surface-raised/10' : 'xl:bg-transparent'
className={`relative flex w-full cursor-pointer flex-col gap-3 rounded-xl border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-white/[0.06] hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
baseStriped ? 'bg-white/[0.045]' : 'bg-white/[0.015]'
} ${smStriped ? 'sm:bg-white/[0.045]' : 'sm:bg-white/[0.015]'} ${
xlStriped ? 'xl:bg-white/[0.045]' : 'xl:bg-white/[0.015]'
} ${
plugin.isInstalled ? 'border-l-2 border-border border-l-emerald-500/35' : 'border-border'
}`}

View file

@ -30,9 +30,16 @@ const skillEditorTheme = EditorView.theme({
interface SkillCodeEditorProps {
value: string;
onChange: (value: string) => void;
scrollRef?: React.RefObject<HTMLElement | null>;
onScroll?: () => void;
}
export const SkillCodeEditor = ({ value, onChange }: SkillCodeEditorProps): React.JSX.Element => {
export const SkillCodeEditor = ({
value,
onChange,
scrollRef,
onScroll,
}: SkillCodeEditorProps): React.JSX.Element => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
@ -72,13 +79,27 @@ export const SkillCodeEditor = ({ value, onChange }: SkillCodeEditorProps): Reac
});
viewRef.current = view;
if (onScroll) {
view.scrollDOM.addEventListener('scroll', onScroll, { passive: true });
}
if (scrollRef && 'current' in scrollRef) {
const mutableRef = scrollRef as React.MutableRefObject<HTMLElement | null>;
mutableRef.current = view.scrollDOM;
}
return () => {
if (onScroll) {
view.scrollDOM.removeEventListener('scroll', onScroll);
}
if (scrollRef && 'current' in scrollRef) {
const mutableRef = scrollRef as React.MutableRefObject<HTMLElement | null>;
mutableRef.current = null;
}
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- create editor once per mount
}, []);
}, [onScroll, scrollRef]);
useEffect(() => {
const view = viewRef.current;

View file

@ -1,8 +1,18 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { CodeBlockViewer } from '@renderer/components/chat/viewers/CodeBlockViewer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@renderer/components/ui/alert-dialog';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
@ -34,18 +44,29 @@ export const SkillDetailDialog = ({
}: 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
);
const detailError = useStore((s) =>
skillId ? (s.skillsDetailErrorById[skillId] ?? null) : null
);
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
useEffect(() => {
if (!open || !skillId) return;
if (detail === undefined) {
void fetchSkillDetail(skillId, projectPath ?? undefined);
void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined);
}, [fetchSkillDetail, open, projectPath, skillId]);
useEffect(() => {
if (!open) {
setDeleteError(null);
setDeleteLoading(false);
setDeleteConfirmOpen(false);
}
}, [detail, fetchSkillDetail, open, projectPath, skillId]);
}, [open]);
const item = detail?.item;
@ -53,16 +74,32 @@ export const SkillDetailDialog = ({
return `.${rootKind}`;
}
function formatScopeLabel(scope: 'user' | 'project'): string {
return scope === 'project' ? 'This project only' : 'Your personal skills';
}
function formatInvocationLabel(invocationMode: 'auto' | 'manual-only'): string {
return invocationMode === 'manual-only'
? 'Claude will only use this when you explicitly ask for it.'
: 'Claude can pick this automatically when it matches the task.';
}
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();
setDeleteLoading(true);
setDeleteError(null);
try {
await deleteSkill({
skillId: item.id,
projectPath: projectPath ?? undefined,
});
setDeleteConfirmOpen(false);
onDeleted();
} catch (error) {
setDeleteError(error instanceof Error ? error.message : 'Failed to delete skill');
} finally {
setDeleteLoading(false);
}
}
return (
@ -79,7 +116,24 @@ export const SkillDetailDialog = ({
<p className="text-sm text-text-muted">Loading skill details...</p>
)}
{!loading && detail === null && (
{!loading && detailError && (
<div className="space-y-3 rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
<p>{detailError}</p>
{skillId && (
<Button
variant="outline"
size="sm"
onClick={() => {
void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined);
}}
>
Retry
</Button>
)}
</div>
)}
{!loading && !detailError && 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>
@ -87,17 +141,27 @@ export const SkillDetailDialog = ({
{!loading && detail && item && (
<div className="space-y-4">
{deleteError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400">
{deleteError}
</div>
)}
<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>}
<Badge variant="outline">{formatScopeLabel(item.scope)}</Badge>
<Badge variant="outline">Stored in {formatRootKind(item.rootKind)}</Badge>
<Badge variant="secondary">
{item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'}
</Badge>
{item.flags.hasScripts && <Badge variant="destructive">Has 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">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
Review this skill carefully before using it
</p>
{item.issues.map((issue, index) => (
<div
key={`${issue.code}-${index}`}
@ -110,6 +174,35 @@ export const SkillDetailDialog = ({
</div>
)}
<div className="grid gap-3 rounded-lg border border-border p-4 md:grid-cols-3">
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
Who can use it
</p>
<p className="text-sm text-text">{formatScopeLabel(item.scope)}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
How Claude uses it
</p>
<p className="text-sm text-text">{formatInvocationLabel(item.invocationMode)}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
What comes with it
</p>
<p className="text-sm text-text">
{[
item.flags.hasReferences ? 'references' : null,
item.flags.hasScripts ? 'scripts' : null,
item.flags.hasAssets ? 'assets' : null,
]
.filter(Boolean)
.join(', ') || 'Just the skill instructions'}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={onEdit}>
<Pencil className="mr-1.5 size-3.5" />
@ -118,27 +211,11 @@ export const SkillDetailDialog = ({
<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}
onClick={() => setDeleteConfirmOpen(true)}
disabled={deleteLoading}
>
<Trash2 className="mr-1.5 size-3.5" />
Delete
{deleteLoading ? 'Deleting...' : 'Delete'}
</Button>
</div>
@ -153,15 +230,9 @@ export const SkillDetailDialog = ({
</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="font-medium text-text">Stored at</p>
<p className="break-all text-xs text-text-muted">{item.skillDir}</p>
</div>
@ -198,11 +269,61 @@ export const SkillDetailDialog = ({
</div>
)}
</div>
<details className="rounded-lg border border-border p-3 text-sm text-text-secondary">
<summary className="cursor-pointer font-medium text-text">
Advanced file details
</summary>
<div className="mt-3 space-y-3">
<div className="flex flex-wrap gap-2">
<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>
</div>
<CodeBlockViewer
fileName={item.skillFile}
content={detail.rawContent}
maxHeight="max-h-72"
/>
</div>
</details>
</div>
</div>
</div>
)}
</DialogContent>
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete skill?</AlertDialogTitle>
<AlertDialogDescription>
{item
? `Delete "${item.name}" and move it to Trash? You can restore it later from Trash if needed.`
: 'Delete this skill and move it to Trash?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => void handleDelete()} disabled={deleteLoading}>
{deleteLoading ? 'Deleting...' : 'Delete Skill'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
};

View file

@ -8,7 +8,6 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
@ -21,6 +20,8 @@ import {
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Textarea } from '@renderer/components/ui/textarea';
import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync';
import { useStore } from '@renderer/store';
import { FileSearch, RotateCcw, X } from 'lucide-react';
@ -29,12 +30,11 @@ import { SkillReviewDialog } from './SkillReviewDialog';
import {
buildSkillDraftFiles,
buildSkillTemplate,
readSkillTemplateInput,
readSkillTemplateContent,
updateSkillTemplateFrontmatter,
} from './skillDraftUtils';
import type {
SkillCatalogItem,
SkillDetail,
SkillInvocationMode,
SkillReviewPreview,
@ -60,6 +60,16 @@ function parseInitialDescription(detail: SkillDetail | null): string {
return detail?.item.description ?? '';
}
function toSuggestedFolderName(value: string): string {
return value
.normalize('NFKD')
.replace(/[^\x00-\x7F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
export const SkillEditorDialog = ({
open,
mode,
@ -70,11 +80,10 @@ export const SkillEditorDialog = ({
onSaved,
}: SkillEditorDialogProps): React.JSX.Element => {
const containerRef = useRef<HTMLDivElement>(null);
const editorScrollRef = useRef<HTMLElement | null>(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');
@ -84,17 +93,31 @@ export const SkillEditorDialog = ({
const [license, setLicense] = useState('');
const [compatibility, setCompatibility] = useState('');
const [invocationMode, setInvocationMode] = useState<SkillInvocationMode>('auto');
const [whenToUse, setWhenToUse] = useState('');
const [steps, setSteps] = useState('');
const [notes, setNotes] = useState('');
const [includeScripts, setIncludeScripts] = useState(false);
const [includeReferences, setIncludeReferences] = useState(false);
const [includeAssets, setIncludeAssets] = useState(false);
const [rawContent, setRawContent] = useState('');
const [folderNameEdited, setFolderNameEdited] = useState(false);
const [customMarkdownDetected, setCustomMarkdownDetected] = useState(false);
const [manualRawEdit, setManualRawEdit] = useState(false);
const [showAdvancedEditor, setShowAdvancedEditor] = 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 [reviewLoading, setReviewLoading] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [mutationError, setMutationError] = useState<string | null>(null);
const scrollSync = useMarkdownScrollSync(
showAdvancedEditor,
detail?.item.id ?? (mode === 'create' ? 'create-skill' : 'edit-skill'),
{ editorScrollRef }
);
const applyMetadataToRawContent = useCallback(
const applyFormToRawContent = useCallback(
(
nextValues: Partial<{
name: string;
@ -102,6 +125,9 @@ export const SkillEditorDialog = ({
license: string;
compatibility: string;
invocationMode: SkillInvocationMode;
whenToUse: string;
steps: string;
notes: string;
}>
) => {
const merged = {
@ -110,17 +136,31 @@ export const SkillEditorDialog = ({
license,
compatibility,
invocationMode,
whenToUse,
steps,
notes,
...nextValues,
};
const nextRawContent =
mode === 'create' && !manualRawEdit
!manualRawEdit && !customMarkdownDetected
? buildSkillTemplate(merged)
: updateSkillTemplateFrontmatter(rawContentRef.current, merged);
rawContentRef.current = nextRawContent;
setRawContent(nextRawContent);
},
[compatibility, description, invocationMode, license, manualRawEdit, mode, name]
[
compatibility,
description,
invocationMode,
license,
manualRawEdit,
customMarkdownDetected,
name,
notes,
steps,
whenToUse,
]
);
useEffect(() => {
@ -135,6 +175,9 @@ export const SkillEditorDialog = ({
const nextLicense = item?.license ?? '';
const nextCompatibility = item?.compatibility ?? '';
const nextInvocationMode = item?.invocationMode ?? 'auto';
const nextWhenToUse = 'Use this skill when the task matches these conditions.';
const nextSteps = '1. Describe the first step.\n2. Describe the second step.';
const nextNotes = '- Add caveats, review rules, or references.';
const nextRawContent =
detail?.rawContent ??
buildSkillTemplate({
@ -143,12 +186,18 @@ export const SkillEditorDialog = ({
license: nextLicense,
compatibility: nextCompatibility,
invocationMode: nextInvocationMode,
whenToUse: nextWhenToUse,
steps: nextSteps,
notes: nextNotes,
});
const rawInput = readSkillTemplateInput(nextRawContent);
const rawInput = readSkillTemplateContent(nextRawContent);
const suggestedFolderName = toSuggestedFolderName(nextName || 'New Skill');
const hasCustomMarkdown = mode === 'edit' && rawInput.hasUnstructuredBody;
setScope(nextScope);
setRootKind(nextRootKind);
setFolderName(nextFolderName || nextName || '');
setFolderName(nextFolderName || suggestedFolderName || nextName || '');
setFolderNameEdited(Boolean(item?.folderName));
setName(rawInput.name || nextName || 'New Skill');
setDescription(
rawInput.description || nextDescription || 'Describe what this skill helps with.'
@ -156,14 +205,26 @@ export const SkillEditorDialog = ({
setLicense(rawInput.license ?? nextLicense);
setCompatibility(rawInput.compatibility ?? nextCompatibility);
setInvocationMode(rawInput.invocationMode ?? nextInvocationMode);
setWhenToUse(
hasCustomMarkdown
? (rawInput.bodyMarkdown ?? nextRawContent)
: (rawInput.whenToUse ?? nextWhenToUse)
);
setSteps(hasCustomMarkdown ? '' : (rawInput.steps ?? nextSteps));
setNotes(hasCustomMarkdown ? '' : (rawInput.notes ?? nextNotes));
setIncludeScripts(item?.flags.hasScripts ?? false);
setIncludeReferences(item?.flags.hasReferences ?? false);
setIncludeAssets(item?.flags.hasAssets ?? false);
setCustomMarkdownDetected(hasCustomMarkdown);
rawContentRef.current = nextRawContent;
setRawContent(nextRawContent);
setManualRawEdit(false);
setShowAdvancedEditor(hasCustomMarkdown);
setReviewPreview(null);
setReviewOpen(false);
setReviewLoading(false);
setSaveLoading(false);
setMutationError(null);
}, [detail, mode, open, projectPath]);
useEffect(() => {
@ -207,11 +268,28 @@ export const SkillEditorDialog = ({
);
const canUseProjectScope = Boolean(projectPath);
const instructionsLocked = manualRawEdit || customMarkdownDetected;
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.';
? 'Describe the workflow in plain language, review the files that will be created, then save it.'
: 'Update this skill, review the resulting file changes, then save it.';
function validateBeforeReview(): string | null {
if (!name.trim()) {
return 'Add a skill name so people know what this workflow is for.';
}
if (!description.trim()) {
return 'Add a short description so it is clear what this skill helps with.';
}
if (!folderName.trim()) {
return 'Choose a folder name for this skill.';
}
if (scope === 'project' && !projectPath) {
return 'Project skills need an active project.';
}
return null;
}
const handleMouseMove = useCallback((event: MouseEvent): void => {
const container = containerRef.current;
@ -242,16 +320,40 @@ export const SkillEditorDialog = ({
}, [handleMouseMove, handleMouseUp, isResizing]);
async function handleReview(): Promise<void> {
const preview = await previewSkillUpsert(request);
setReviewPreview(preview);
setReviewOpen(true);
const validationError = validateBeforeReview();
if (validationError) {
setMutationError(validationError);
return;
}
setReviewLoading(true);
setMutationError(null);
try {
const preview = await previewSkillUpsert(request);
setReviewPreview(preview);
setReviewOpen(true);
} catch (error) {
setMutationError(error instanceof Error ? error.message : 'Failed to review skill changes');
} finally {
setReviewLoading(false);
}
}
async function handleConfirmSave(): Promise<void> {
const saved = await applySkillUpsert(request);
setReviewOpen(false);
onSaved(saved?.item.id ?? detail?.item.id ?? null);
onClose();
setSaveLoading(true);
setMutationError(null);
try {
const saved = await applySkillUpsert({
...request,
reviewPlanId: reviewPreview?.planId,
});
setReviewOpen(false);
onSaved(saved?.item.id ?? detail?.item.id ?? null);
onClose();
} catch (error) {
setMutationError(error instanceof Error ? error.message : 'Failed to save skill');
} finally {
setSaveLoading(false);
}
}
return (
@ -266,9 +368,17 @@ export const SkillEditorDialog = ({
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="space-y-5">
<section className="space-y-1">
<h3 className="text-sm font-semibold text-text">1. Basics</h3>
<p className="text-sm text-text-muted">
Give this skill a clear name, choose who can use it, and decide where it should
live.
</p>
</section>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="skill-scope">Scope</Label>
<Label htmlFor="skill-scope">Who can use it</Label>
<Select
value={scope}
onValueChange={(value) => setScope(value as 'user' | 'project')}
@ -289,7 +399,7 @@ export const SkillEditorDialog = ({
</div>
<div className="space-y-2">
<Label htmlFor="skill-root">Root</Label>
<Label htmlFor="skill-root">Where to store it</Label>
<Select
value={rootKind}
onValueChange={(value) =>
@ -313,27 +423,36 @@ export const SkillEditorDialog = ({
<Input
id="skill-folder"
value={folderName}
onChange={(event) => setFolderName(event.target.value)}
onChange={(event) => {
setFolderNameEdited(true);
setFolderName(event.target.value);
}}
disabled={mode === 'edit'}
/>
{mode === 'create' && (
<p className="text-xs text-text-muted">
We suggest this automatically from the skill name so review works right
away.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="skill-invocation">Invocation</Label>
<Label htmlFor="skill-invocation">How Claude should use it</Label>
<Select
value={invocationMode}
onValueChange={(value) => {
const nextValue = value as SkillInvocationMode;
setInvocationMode(nextValue);
applyMetadataToRawContent({ invocationMode: nextValue });
applyFormToRawContent({ invocationMode: nextValue });
}}
>
<SelectTrigger id="skill-invocation">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="manual-only">Manual only</SelectItem>
<SelectItem value="auto">Claude can use it automatically</SelectItem>
<SelectItem value="manual-only">Only when you ask for it</SelectItem>
</SelectContent>
</Select>
</div>
@ -348,7 +467,10 @@ export const SkillEditorDialog = ({
onChange={(event) => {
const nextValue = event.target.value;
setName(nextValue);
applyMetadataToRawContent({ name: nextValue });
if (mode === 'create' && !folderNameEdited) {
setFolderName(toSuggestedFolderName(nextValue || 'New Skill'));
}
applyFormToRawContent({ name: nextValue });
}}
placeholder="Write concise skill name"
/>
@ -361,7 +483,7 @@ export const SkillEditorDialog = ({
onChange={(event) => {
const nextValue = event.target.value;
setLicense(nextValue);
applyMetadataToRawContent({ license: nextValue });
applyFormToRawContent({ license: nextValue });
}}
placeholder="MIT"
/>
@ -377,7 +499,7 @@ export const SkillEditorDialog = ({
onChange={(event) => {
const nextValue = event.target.value;
setDescription(nextValue);
applyMetadataToRawContent({ description: nextValue });
applyFormToRawContent({ description: nextValue });
}}
placeholder="What this skill helps with"
/>
@ -390,13 +512,90 @@ export const SkillEditorDialog = ({
onChange={(event) => {
const nextValue = event.target.value;
setCompatibility(nextValue);
applyMetadataToRawContent({ compatibility: nextValue });
applyFormToRawContent({ compatibility: nextValue });
}}
placeholder="claude-code, cursor"
/>
</div>
</div>
{!customMarkdownDetected && (
<>
<section className="space-y-1">
<h3 className="text-sm font-semibold text-text">2. Instructions</h3>
<p className="text-sm text-text-muted">
These sections generate the skill file for you, so you do not need to edit
markdown unless you want to.
</p>
</section>
<div className="grid gap-3">
<div className="space-y-2">
<Label htmlFor="skill-when-to-use">When Claude should reach for this</Label>
<Textarea
id="skill-when-to-use"
value={whenToUse}
disabled={instructionsLocked}
onChange={(event) => {
const nextValue = event.target.value;
setWhenToUse(nextValue);
applyFormToRawContent({ whenToUse: nextValue });
}}
placeholder="Example: Use this when the task is a code review or bug triage request."
className="min-h-[88px]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="skill-steps">Main steps Claude should follow</Label>
<Textarea
id="skill-steps"
value={steps}
disabled={instructionsLocked}
onChange={(event) => {
const nextValue = event.target.value;
setSteps(nextValue);
applyFormToRawContent({ steps: nextValue });
}}
placeholder={
'1. Inspect the relevant files.\n2. Explain the main risk first.\n3. Suggest the safest fix.'
}
className="min-h-[120px]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="skill-notes">Extra notes or guardrails</Label>
<Textarea
id="skill-notes"
value={notes}
disabled={instructionsLocked}
onChange={(event) => {
const nextValue = event.target.value;
setNotes(nextValue);
applyFormToRawContent({ notes: nextValue });
}}
placeholder="Example: Call out missing tests, regressions, and risky assumptions."
className="min-h-[88px]"
/>
{instructionsLocked && (
<p className="text-xs text-text-muted">
Structured fields are locked because you switched to manual `SKILL.md`
editing below.
</p>
)}
</div>
</div>
</>
)}
<section className="space-y-1">
<h3 className="text-sm font-semibold text-text">3. Extra files</h3>
<p className="text-sm text-text-muted">
Add supporting docs, scripts, or assets only if this skill really needs them.
</p>
</section>
<div className="rounded-lg border border-border p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
@ -423,7 +622,7 @@ export const SkillEditorDialog = ({
<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.
Add supporting docs, links, or examples that Claude can look at.
</p>
</div>
</label>
@ -437,7 +636,8 @@ export const SkillEditorDialog = ({
<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.
Add helper commands or setup notes. Review carefully before sharing this
skill.
</p>
</div>
</label>
@ -451,7 +651,7 @@ export const SkillEditorDialog = ({
<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.
Add screenshots or bundled media only if they help explain the workflow.
</p>
</div>
</label>
@ -473,74 +673,117 @@ export const SkillEditorDialog = ({
)}
</div>
{skillsMutationError && (
{mutationError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400">
{skillsMutationError}
{mutationError}
</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>
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text">
{customMarkdownDetected
? '2. SKILL.md editor'
: '4. Advanced SKILL.md editor'}
</h3>
<p className="text-sm text-text-muted">
{customMarkdownDetected
? 'This skill uses a custom markdown format, so edit it directly here.'
: 'Most people can skip this. Open it only if you want direct control over the raw markdown file.'}
</p>
</div>
{!customMarkdownDetected && (
<Button
variant="outline"
size="sm"
onClick={() => setShowAdvancedEditor((prev) => !prev)}
>
{showAdvancedEditor ? 'Hide Advanced Editor' : 'Show Advanced Editor'}
</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);
{showAdvancedEditor && (
<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,
whenToUse,
steps,
notes,
});
rawContentRef.current = nextRawContent;
setRawContent(nextRawContent);
}}
>
<RotateCcw className="mr-1.5 size-3.5" />
Reset From Structured Fields
</Button>
</div>
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
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}
scrollRef={editorScrollRef}
onScroll={scrollSync.handleCodeScroll}
onChange={(value) => {
setManualRawEdit(true);
rawContentRef.current = value;
setRawContent(value);
const rawInput = readSkillTemplateContent(value);
setCustomMarkdownDetected(rawInput.hasUnstructuredBody);
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);
if (rawInput.whenToUse !== undefined)
setWhenToUse(rawInput.whenToUse);
if (rawInput.steps !== undefined) setSteps(rawInput.steps);
if (rawInput.notes !== undefined) setNotes(rawInput.notes);
}}
/>
</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}
scrollRef={scrollSync.previewScrollRef}
onScroll={scrollSync.handlePreviewScroll}
/>
</div>
</div>
</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>
)}
</section>
</div>
</div>
@ -549,12 +792,15 @@ export const SkillEditorDialog = ({
<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}>
<div className="min-w-[16rem] flex-1">
<p className="text-sm text-text-muted">
Review the file changes first, then confirm save in the next step.
</p>
{mutationError && <p className="mt-1 text-sm text-red-400">{mutationError}</p>}
</div>
<Button onClick={() => void handleReview()} disabled={reviewLoading || saveLoading}>
<FileSearch className="mr-1.5 size-3.5" />
{skillsMutationLoading
{reviewLoading
? 'Preparing...'
: mode === 'create'
? 'Review And Create'
@ -568,7 +814,8 @@ export const SkillEditorDialog = ({
<SkillReviewDialog
open={reviewOpen}
preview={reviewPreview}
loading={skillsMutationLoading}
loading={saveLoading}
error={mutationError}
onClose={() => setReviewOpen(false)}
onConfirm={() => void handleConfirmSave()}
confirmLabel={mode === 'create' ? 'Create Skill' : 'Save Skill'}

View file

@ -26,6 +26,28 @@ import { SkillReviewDialog } from './SkillReviewDialog';
import type { SkillReviewPreview } from '@shared/types/extensions';
function getFriendlyImportError(message: string): string {
if (message.includes('valid skill file')) {
return 'This folder does not look like a skill yet. It needs a SKILL.md, Skill.md, or skill.md file.';
}
if (message.includes('symbolic links')) {
return 'This folder contains symbolic links. Import the real files instead of links.';
}
if (message.includes('too many files')) {
return 'This skill folder is too large to import at once. Remove extra files and try again.';
}
if (message.includes('too large')) {
return 'This skill folder is too large to import safely. Trim large assets and try again.';
}
if (message.includes('Invalid folder name')) {
return 'Pick a simpler destination folder name using letters, numbers, dots, dashes, or underscores.';
}
if (message.includes('must be a directory')) {
return 'Choose a folder to import, not a single file.';
}
return message;
}
interface SkillImportDialogProps {
open: boolean;
projectPath: string | null;
@ -43,8 +65,6 @@ export const SkillImportDialog = ({
}: 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('');
@ -52,6 +72,9 @@ export const SkillImportDialog = ({
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
const [preview, setPreview] = useState<SkillReviewPreview | null>(null);
const [reviewOpen, setReviewOpen] = useState(false);
const [reviewLoading, setReviewLoading] = useState(false);
const [importLoading, setImportLoading] = useState(false);
const [mutationError, setMutationError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
@ -61,6 +84,9 @@ export const SkillImportDialog = ({
setRootKind('claude');
setPreview(null);
setReviewOpen(false);
setReviewLoading(false);
setImportLoading(false);
setMutationError(null);
}, [open, projectPath]);
async function handleChooseFolder(): Promise<void> {
@ -75,28 +101,51 @@ export const SkillImportDialog = ({
}
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);
setReviewLoading(true);
setMutationError(null);
try {
const nextPreview = await previewSkillImport({
sourceDir,
folderName: folderName || undefined,
scope,
rootKind,
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
});
setPreview(nextPreview);
setReviewOpen(true);
} catch (error) {
setMutationError(
getFriendlyImportError(
error instanceof Error ? error.message : 'Failed to review import changes'
)
);
} finally {
setReviewLoading(false);
}
}
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();
setImportLoading(true);
setMutationError(null);
try {
const detail = await applySkillImport({
sourceDir,
folderName: folderName || undefined,
scope,
rootKind,
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
reviewPlanId: preview?.planId,
});
setReviewOpen(false);
onImported(detail?.item.id ?? null);
onClose();
} catch (error) {
setMutationError(
getFriendlyImportError(error instanceof Error ? error.message : 'Failed to import skill')
);
} finally {
setImportLoading(false);
}
}
return (
@ -107,13 +156,20 @@ export const SkillImportDialog = ({
<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.
Pick an existing skill folder, review what will be copied, then import it into one
of your supported skill locations.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="space-y-5">
<section className="space-y-1">
<h3 className="text-sm font-semibold text-text">1. Choose a skill folder</h3>
<p className="text-sm text-text-muted">
This should be a folder that already contains a `SKILL.md`, `Skill.md`, or
`skill.md` file.
</p>
</section>
<div className="space-y-2">
<Label htmlFor="skill-import-source">Source folder</Label>
<div className="flex gap-2">
@ -139,9 +195,15 @@ export const SkillImportDialog = ({
/>
</div>
<section className="space-y-1">
<h3 className="text-sm font-semibold text-text">2. Decide where it belongs</h3>
<p className="text-sm text-text-muted">
Personal skills work everywhere. Project skills only show up for one codebase.
</p>
</section>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="skill-import-scope">Scope</Label>
<Label htmlFor="skill-import-scope">Who can use it</Label>
<Select
value={scope}
onValueChange={(value) => setScope(value as 'user' | 'project')}
@ -161,7 +223,7 @@ export const SkillImportDialog = ({
</div>
<div className="space-y-2">
<Label htmlFor="skill-import-root">Root</Label>
<Label htmlFor="skill-import-root">Where to store it</Label>
<Select
value={rootKind}
onValueChange={(value) =>
@ -180,9 +242,9 @@ export const SkillImportDialog = ({
</div>
</div>
{skillsMutationError && (
{mutationError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400">
{skillsMutationError}
{mutationError}
</div>
)}
</div>
@ -198,10 +260,10 @@ export const SkillImportDialog = ({
</p>
<Button
onClick={() => void handleReview()}
disabled={!sourceDir || skillsMutationLoading}
disabled={!sourceDir || reviewLoading || importLoading}
>
<FileSearch className="mr-1.5 size-3.5" />
{skillsMutationLoading ? 'Preparing...' : 'Review And Import'}
{reviewLoading ? 'Preparing...' : 'Review And Import'}
</Button>
</div>
</div>
@ -211,11 +273,13 @@ export const SkillImportDialog = ({
<SkillReviewDialog
open={reviewOpen}
preview={preview}
loading={skillsMutationLoading}
loading={importLoading}
error={mutationError}
onClose={() => setReviewOpen(false)}
onConfirm={() => void handleConfirmImport()}
confirmLabel="Import Skill"
reviewLabel="Importing this skill"
backLabel="Back To Import"
/>
</>
);

View file

@ -17,20 +17,24 @@ interface SkillReviewDialogProps {
open: boolean;
preview: SkillReviewPreview | null;
loading?: boolean;
error?: string | null;
onClose: () => void;
onConfirm: () => void;
confirmLabel: string;
reviewLabel: string;
backLabel?: string;
}
export const SkillReviewDialog = ({
open,
preview,
loading = false,
error = null,
onClose,
onConfirm,
confirmLabel,
reviewLabel,
backLabel = 'Back To Editor',
}: SkillReviewDialogProps): React.JSX.Element => {
const hasChanges = Boolean(preview && preview.changes.length > 0);
@ -54,6 +58,18 @@ export const SkillReviewDialog = ({
<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>
{preview.summary.created > 0 && (
<Badge variant="secondary">{preview.summary.created} new</Badge>
)}
{preview.summary.updated > 0 && (
<Badge variant="outline">{preview.summary.updated} updated</Badge>
)}
{preview.summary.deleted > 0 && (
<Badge variant="destructive">{preview.summary.deleted} removed</Badge>
)}
{preview.summary.binary > 0 && (
<Badge variant="destructive">{preview.summary.binary} binary</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}
@ -73,6 +89,12 @@ export const SkillReviewDialog = ({
</div>
)}
{error && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400">
{error}
</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.
@ -117,7 +139,7 @@ export const SkillReviewDialog = ({
<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
{backLabel}
</Button>
<Button onClick={onConfirm} disabled={loading || !preview || !hasChanges}>
{loading ? (

View file

@ -25,10 +25,13 @@ import { SkillDetailDialog } from './SkillDetailDialog';
import { SkillEditorDialog } from './SkillEditorDialog';
import { SkillImportDialog } from './SkillImportDialog';
import type { SkillCatalogItem } from '@shared/types/extensions';
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState';
const SUCCESS_BANNER_MS = 2500;
const NEW_SKILL_HIGHLIGHT_MS = 4000;
const USER_SKILLS_CATALOG_KEY = '__user__';
type SkillsQuickFilter = 'all' | 'project' | 'personal' | 'needs-attention' | 'has-scripts';
interface SkillsPanelProps {
projectPath: string | null;
@ -56,6 +59,26 @@ function formatRootKind(rootKind: SkillCatalogItem['rootKind']): string {
return `.${rootKind}`;
}
function getScopeLabel(skill: SkillCatalogItem): string {
return skill.scope === 'project' ? 'This project' : 'Personal';
}
function getInvocationLabel(skill: SkillCatalogItem): string {
return skill.invocationMode === 'manual-only'
? 'Only runs when you explicitly ask for it'
: 'Claude can use this automatically when it fits';
}
function getSkillStatus(skill: SkillCatalogItem): string {
if (!skill.isValid) {
return 'Needs attention before you rely on it';
}
if (skill.flags.hasScripts) {
return 'Includes scripts, so review it carefully';
}
return 'Ready to use';
}
export const SkillsPanel = ({
projectPath,
projectLabel,
@ -66,10 +89,11 @@ export const SkillsPanel = ({
selectedSkillId,
setSelectedSkillId,
}: SkillsPanelProps): React.JSX.Element => {
const catalogKey = projectPath ?? USER_SKILLS_CATALOG_KEY;
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 skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
const detailById = useStore((s) => s.skillsDetailsById);
const userSkills = useStore((s) => s.skillsUserCatalog);
const projectSkills = useStore((s) =>
@ -77,9 +101,12 @@ export const SkillsPanel = ({
);
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editingDetail, setEditingDetail] = useState<SkillDetail | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [quickFilter, setQuickFilter] = useState<SkillsQuickFilter>('all');
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [highlightedSkillId, setHighlightedSkillId] = useState<string | null>(null);
const selectedSkillIdRef = useRef<string | null>(selectedSkillId);
selectedSkillIdRef.current = selectedSkillId;
@ -101,6 +128,12 @@ export const SkillsPanel = ({
return () => window.clearTimeout(timeoutId);
}, [successMessage]);
useEffect(() => {
if (!highlightedSkillId) return;
const timeoutId = window.setTimeout(() => setHighlightedSkillId(null), NEW_SKILL_HIGHLIGHT_MS);
return () => window.clearTimeout(timeoutId);
}, [highlightedSkillId]);
useEffect(() => {
const skillsApi = api.skills;
if (!skillsApi) return;
@ -122,7 +155,9 @@ export const SkillsPanel = ({
void fetchSkillsCatalog(projectPath ?? undefined);
if (selectedSkillIdRef.current) {
void fetchSkillDetail(selectedSkillIdRef.current, projectPath ?? undefined);
void fetchSkillDetail(selectedSkillIdRef.current, projectPath ?? undefined).catch(
() => undefined
);
}
});
@ -137,7 +172,7 @@ export const SkillsPanel = ({
const visibleSkills = useMemo(() => {
const q = skillsSearchQuery.trim().toLowerCase();
const filtered = q
const filteredByQuery = q
? mergedSkills.filter(
(skill) =>
skill.name.toLowerCase().includes(q) ||
@ -145,8 +180,34 @@ export const SkillsPanel = ({
skill.folderName.toLowerCase().includes(q)
)
: mergedSkills;
const filtered =
quickFilter === 'all'
? filteredByQuery
: filteredByQuery.filter((skill) => {
switch (quickFilter) {
case 'project':
return skill.scope === 'project';
case 'personal':
return skill.scope === 'user';
case 'needs-attention':
return !skill.isValid;
case 'has-scripts':
return skill.flags.hasScripts;
default:
return true;
}
});
return sortSkills(filtered, skillsSort);
}, [mergedSkills, skillsSearchQuery, skillsSort]);
}, [mergedSkills, quickFilter, skillsSearchQuery, skillsSort]);
const visibleProjectSkills = useMemo(
() => visibleSkills.filter((skill) => skill.scope === 'project'),
[visibleSkills]
);
const visibleUserSkills = useMemo(
() => visibleSkills.filter((skill) => skill.scope === 'user'),
[visibleSkills]
);
const isRefreshing = skillsLoading && mergedSkills.length > 0;
return (
<div className="flex flex-col gap-4">
@ -155,12 +216,18 @@ export const SkillsPanel = ({
<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>
<h2 className="text-sm font-semibold text-text">Teach Claude repeatable work</h2>
</div>
<p className="max-w-2xl text-sm leading-5 text-text-muted">
Skills are reusable instructions that help Claude handle the same kind of task more
consistently.{' '}
{projectPath
? `Project skills for ${projectLabel ?? projectPath} plus your user-level skills.`
: 'User-level skills only. Select a project to include project-scoped skill roots.'}
? `You are seeing skills for ${projectLabel ?? projectPath} plus your personal skills.`
: 'You are seeing only your personal skills right now.'}
</p>
<p className="max-w-2xl text-xs leading-5 text-text-muted">
Use personal skills for habits you want everywhere. Use project skills for workflows
that only make sense inside one codebase.
</p>
</div>
@ -170,7 +237,7 @@ export const SkillsPanel = ({
<SearchInput
value={skillsSearchQuery}
onChange={setSkillsSearchQuery}
placeholder="Search skills..."
placeholder="Search by skill name or what it helps with..."
/>
</div>
<div className="flex flex-wrap gap-2">
@ -230,19 +297,41 @@ export const SkillsPanel = ({
<div className="flex flex-wrap gap-2 text-[11px] text-text-muted xl:justify-end">
<Badge variant="secondary" className="font-normal">
{mergedSkills.length} discovered
{mergedSkills.length} total
</Badge>
<Badge variant="secondary" className="font-normal">
{projectSkills.length} project
</Badge>
<Badge variant="secondary" className="font-normal">
{userSkills.length} user
{userSkills.length} personal
</Badge>
</div>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{(
[
['all', 'All skills'],
['project', 'Project'],
['personal', 'Personal'],
['needs-attention', 'Needs attention'],
['has-scripts', 'Has scripts'],
] as Array<[SkillsQuickFilter, string]>
).map(([value, label]) => (
<Button
key={value}
variant={quickFilter === value ? 'secondary' : 'outline'}
size="sm"
onClick={() => setQuickFilter(value)}
className="rounded-full"
>
{label}
</Button>
))}
</div>
{skillsError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
{skillsError}
@ -256,6 +345,12 @@ export const SkillsPanel = ({
</div>
)}
{isRefreshing && (
<div className="rounded-md border border-blue-500/20 bg-blue-500/10 p-3 text-sm text-blue-700 dark:text-blue-300">
Refreshing skills...
</div>
)}
{skillsLoading && visibleSkills.length === 0 && (
<div className="rounded-lg border border-border p-6 text-sm text-text-muted">
Loading skills...
@ -268,77 +363,183 @@ export const SkillsPanel = ({
<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'}
{skillsSearchQuery ? 'No skills match your search' : 'No skills yet'}
</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.'}
? 'Try a different search term or switch filters.'
: 'Create your first skill to teach Claude a repeatable workflow, or import one you already use.'}
</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}
<div className="space-y-6">
{visibleProjectSkills.length > 0 && (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text">Project skills</h3>
<p className="text-xs text-text-muted">
Workflows that only make sense for this codebase.
</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)}
{visibleProjectSkills.length}
</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>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{visibleProjectSkills.map((skill) => (
<button
key={skill.id}
type="button"
onClick={() => setSelectedSkillId(skill.id)}
className={`rounded-xl border p-4 text-left transition-colors ${
highlightedSkillId === skill.id
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<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="line-clamp-2 text-sm text-text-secondary">
{skill.description}
</p>
</div>
<Badge variant="outline">{getScopeLabel(skill)}</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 className="mt-3 space-y-2 text-xs text-text-muted">
<p>{getInvocationLabel(skill)}</p>
<p>{getSkillStatus(skill)}</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="font-normal">
Stored in {formatRootKind(skill.rootKind)}
</Badge>
{skill.flags.hasScripts && (
<Badge variant="destructive" className="font-normal">
Has 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>
</section>
)}
{visibleUserSkills.length > 0 && (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text">Personal skills</h3>
<p className="text-xs text-text-muted">
Habits and instructions you want Claude to remember everywhere.
</p>
</div>
)}
</button>
))}
<Badge variant="secondary" className="font-normal">
{visibleUserSkills.length}
</Badge>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{visibleUserSkills.map((skill) => (
<button
key={skill.id}
type="button"
onClick={() => setSelectedSkillId(skill.id)}
className={`rounded-xl border p-4 text-left transition-colors ${
highlightedSkillId === skill.id
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<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="line-clamp-2 text-sm text-text-secondary">
{skill.description}
</p>
</div>
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
</div>
<div className="mt-3 space-y-2 text-xs text-text-muted">
<p>{getInvocationLabel(skill)}</p>
<p>{getSkillStatus(skill)}</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="font-normal">
Stored in {formatRootKind(skill.rootKind)}
</Badge>
{skill.flags.hasScripts && (
<Badge variant="destructive" className="font-normal">
Has 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>
</section>
)}
</div>
)}
@ -347,7 +548,12 @@ export const SkillsPanel = ({
open={selectedSkillId !== null}
onClose={() => setSelectedSkillId(null)}
projectPath={projectPath}
onEdit={() => setEditOpen(true)}
onEdit={() => {
if (!selectedDetail) return;
setEditingDetail(selectedDetail);
setSelectedSkillId(null);
setEditOpen(true);
}}
onDeleted={() => setSelectedSkillId(null)}
/>
@ -361,7 +567,8 @@ export const SkillsPanel = ({
onSaved={(skillId) => {
setCreateOpen(false);
setSuccessMessage('Skill created successfully.');
setSelectedSkillId(skillId);
setHighlightedSkillId(skillId);
setSelectedSkillId(null);
}}
/>
@ -370,10 +577,14 @@ export const SkillsPanel = ({
mode="edit"
projectPath={projectPath}
projectLabel={projectLabel}
detail={selectedDetail}
onClose={() => setEditOpen(false)}
detail={editingDetail}
onClose={() => {
setEditOpen(false);
setEditingDetail(null);
}}
onSaved={(skillId) => {
setEditOpen(false);
setEditingDetail(null);
setSuccessMessage('Skill saved successfully.');
setSelectedSkillId(skillId);
}}

View file

@ -3,6 +3,7 @@ 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;
const SECTION_TITLES = ['When to use', 'Steps', 'Notes'] as const;
export interface SkillDraftOptions {
rawContent: string;
@ -11,6 +12,12 @@ export interface SkillDraftOptions {
includeAssets: boolean;
}
export interface SkillTemplateParseResult extends Partial<SkillDraftTemplateInput> {
bodyMarkdown?: string;
hasStructuredSections: boolean;
hasUnstructuredBody: boolean;
}
function trimTrailingWhitespace(value: string): string {
return value
.split('\n')
@ -20,6 +27,16 @@ function trimTrailingWhitespace(value: string): string {
}
export function buildSkillTemplate(input: SkillDraftTemplateInput): string {
const whenToUse = normalizeSectionContent(input.whenToUse, [
'- Add the conditions where this skill should be selected.',
]);
const steps = normalizeSectionContent(input.steps, [
'1. Describe the first step.',
'2. Describe the second step.',
]);
const notes = normalizeSectionContent(input.notes, [
'- Add caveats, review rules, or references.',
]);
const lines = [
'---',
`name: ${input.name || 'New Skill'}`,
@ -34,45 +51,80 @@ export function buildSkillTemplate(input: SkillDraftTemplateInput): string {
input.description || 'Describe what this skill helps with.',
'',
'## When to use',
'- Add the conditions where this skill should be selected.',
...whenToUse,
'',
'## Steps',
'1. Describe the first step.',
'2. Describe the second step.',
...steps,
'',
'## Notes',
'- Add caveats, review rules, or references.',
...notes,
];
return trimTrailingWhitespace(lines.join('\n'));
}
export function readSkillTemplateInput(rawContent: string): Partial<SkillDraftTemplateInput> {
export function readSkillTemplateContent(rawContent: string): SkillTemplateParseResult {
const content = rawContent.replace(/^\uFEFF/u, '');
const match = content.match(SKILL_FRONTMATTER_PATTERN);
if (!match) {
return {};
return {
hasStructuredSections: false,
hasUnstructuredBody: content.trim().length > 0,
bodyMarkdown: content.trim() || undefined,
};
}
try {
const parsed = YAML.parse(match[1]);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
const bodyMarkdown = (match[2] ?? '').trim() || undefined;
return {
hasStructuredSections: false,
hasUnstructuredBody: Boolean(bodyMarkdown),
bodyMarkdown,
};
}
const data = parsed as Record<string, unknown>;
const body = match[2] ?? '';
const whenToUse = extractSection(body, 'When to use');
const steps = extractSection(body, 'Steps');
const notes = extractSection(body, 'Notes');
const bodyMarkdown = body.trim() || undefined;
const hasStructuredSections = Boolean(whenToUse || steps || notes);
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',
whenToUse,
steps,
notes,
bodyMarkdown,
hasStructuredSections,
hasUnstructuredBody: Boolean(bodyMarkdown) && !hasStructuredSections,
};
} catch {
return {};
const bodyMarkdown = (match[2] ?? '').trim() || undefined;
return {
hasStructuredSections: false,
hasUnstructuredBody: Boolean(bodyMarkdown),
bodyMarkdown,
};
}
}
export function readSkillTemplateInput(rawContent: string): Partial<SkillDraftTemplateInput> {
const {
bodyMarkdown: _bodyMarkdown,
hasStructuredSections: _hasStructuredSections,
hasUnstructuredBody: _hasUnstructuredBody,
...input
} = readSkillTemplateContent(rawContent);
return input;
}
export function updateSkillTemplateFrontmatter(
rawContent: string,
input: SkillDraftTemplateInput
@ -145,3 +197,38 @@ export function buildSkillDraftFiles(options: SkillDraftOptions): SkillDraftFile
return files;
}
function normalizeSectionContent(value: string, fallbackLines: string[]): string[] {
const lines = value
.split('\n')
.map((line) => line.trimEnd())
.filter(
(line, index, allLines) =>
line.length > 0 || allLines.some((candidate) => candidate.length > 0)
);
return lines.some((line) => line.trim().length > 0) ? lines : fallbackLines;
}
function extractSection(body: string, title: (typeof SECTION_TITLES)[number]): string | undefined {
const normalizedBody = body.replace(/\r\n/g, '\n');
const heading = `## ${title}\n`;
const startIndex = normalizedBody.indexOf(heading);
if (startIndex === -1) {
return undefined;
}
const bodyStartIndex = startIndex + heading.length;
const nextSectionIndex = SECTION_TITLES.map((sectionTitle) =>
sectionTitle === title ? -1 : normalizedBody.indexOf(`\n## ${sectionTitle}\n`, bodyStartIndex)
)
.filter((index) => index >= 0)
.sort((a, b) => a - b)[0];
const rawSection =
nextSectionIndex === undefined
? normalizedBody.slice(bodyStartIndex)
: normalizedBody.slice(bodyStartIndex, nextSectionIndex);
return rawSection.trim() || undefined;
}

View file

@ -0,0 +1,127 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { buttonVariants } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4">
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'pointer-events-auto grid w-full max-w-lg gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
className
)}
{...props}
/>
</div>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
const AlertDialogTitle = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight text-[var(--color-text)]',
className
)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-[var(--color-text-muted)]', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants({ variant: 'destructive' }), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */

View file

@ -29,10 +29,16 @@ const ATTACH_RETRY_INTERVAL = 50;
export interface UseMarkdownScrollSyncResult {
previewScrollRef: React.RefObject<HTMLDivElement | null>;
/** Attach to editor scroll container when using a local CodeMirror instance */
handleCodeScroll: () => void;
/** Attach to preview div's onScroll */
handlePreviewScroll: () => void;
}
interface UseMarkdownScrollSyncOptions {
editorScrollRef?: React.RefObject<HTMLElement | null>;
}
// =============================================================================
// Hook
// =============================================================================
@ -46,13 +52,17 @@ export interface UseMarkdownScrollSyncResult {
*/
export function useMarkdownScrollSync(
enabled: boolean,
viewKey?: string | null
viewKey?: string | null,
options?: UseMarkdownScrollSyncOptions
): UseMarkdownScrollSyncResult {
const previewScrollRef = useRef<HTMLDivElement | null>(null);
const ignoreCodeScroll = useRef(false);
const ignorePreviewScroll = useRef(false);
const codeRafRef = useRef(0);
const previewRafRef = useRef(0);
const getEditorScrollElement = useCallback(() => {
return options?.editorScrollRef?.current ?? editorBridge.getView()?.scrollDOM ?? null;
}, [options?.editorScrollRef]);
// Code → Preview: proportional scroll
const handleCodeScroll = useCallback(() => {
@ -62,7 +72,7 @@ export function useMarkdownScrollSync(
return;
}
const scrollDOM = editorBridge.getView()?.scrollDOM;
const scrollDOM = getEditorScrollElement();
const preview = previewScrollRef.current;
if (!scrollDOM || !preview) return;
@ -78,7 +88,7 @@ export function useMarkdownScrollSync(
ignorePreviewScroll.current = true;
preview.scrollTop = fraction * maxPreview;
});
}, [enabled]);
}, [enabled, getEditorScrollElement]);
// Preview → Code: proportional scroll
const handlePreviewScroll = useCallback(() => {
@ -88,7 +98,7 @@ export function useMarkdownScrollSync(
return;
}
const scrollDOM = editorBridge.getView()?.scrollDOM;
const scrollDOM = getEditorScrollElement();
const preview = previewScrollRef.current;
if (!scrollDOM || !preview) return;
@ -104,7 +114,7 @@ export function useMarkdownScrollSync(
ignoreCodeScroll.current = true;
scrollDOM.scrollTop = fraction * maxCode;
});
}, [enabled]);
}, [enabled, getEditorScrollElement]);
// Auto-attach code scroll listener with retry on mount/viewKey change
useEffect(() => {
@ -115,7 +125,7 @@ export function useMarkdownScrollSync(
let attempts = 0;
const tryAttach = (): void => {
const scrollDOM = editorBridge.getView()?.scrollDOM;
const scrollDOM = getEditorScrollElement();
if (!scrollDOM) {
if (attempts < MAX_ATTACH_ATTEMPTS) {
attempts++;
@ -138,10 +148,11 @@ export function useMarkdownScrollSync(
cancelAnimationFrame(codeRafRef.current);
cancelAnimationFrame(previewRafRef.current);
};
}, [enabled, viewKey, handleCodeScroll]);
}, [enabled, viewKey, handleCodeScroll, getEditorScrollElement]);
return {
previewScrollRef,
handleCodeScroll,
handlePreviewScroll,
};
}

View file

@ -68,10 +68,13 @@ export interface ExtensionsSlice {
// ── Skills catalog cache ──
skillsUserCatalog: SkillCatalogItem[];
skillsProjectCatalogByProjectPath: Record<string, SkillCatalogItem[]>;
skillsCatalogLoadingByProjectPath: Record<string, boolean>;
skillsCatalogErrorByProjectPath: Record<string, string | null>;
skillsLoading: boolean;
skillsError: string | null;
skillsDetailsById: Record<string, SkillDetail | null | undefined>;
skillsDetailLoadingById: Record<string, boolean>;
skillsDetailErrorById: Record<string, string | null>;
skillsMutationLoading: boolean;
skillsMutationError: string | null;
@ -123,6 +126,20 @@ export interface ExtensionsSlice {
let pluginFetchInFlight: Promise<void> | null = null;
let mcpDiagnosticsInFlight: Promise<void> | null = null;
let skillsCatalogRequestSeq = 0;
let skillsDetailRequestSeq = 0;
const latestSkillsCatalogRequestByKey = new Map<string, number>();
const latestSkillsDetailRequestById = new Map<string, number>();
const USER_SKILLS_CATALOG_KEY = '__user__';
function hasAnyLoading(loadingMap: Record<string, boolean>): boolean {
return Object.values(loadingMap).some(Boolean);
}
function getSkillsCatalogKey(projectPath?: string): string {
return projectPath ?? USER_SKILLS_CATALOG_KEY;
}
/** Duration to show "success" state before returning to idle */
const SUCCESS_DISPLAY_MS = 2_000;
@ -162,10 +179,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
skillsUserCatalog: [],
skillsProjectCatalogByProjectPath: {},
skillsCatalogLoadingByProjectPath: {},
skillsCatalogErrorByProjectPath: {},
skillsLoading: false,
skillsError: null,
skillsDetailsById: {},
skillsDetailLoadingById: {},
skillsDetailErrorById: {},
skillsMutationLoading: false,
skillsMutationError: null,
@ -317,11 +337,44 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
fetchSkillsCatalog: async (projectPath?: string) => {
if (!api.skills) return;
set({ skillsLoading: true, skillsError: null });
const requestKey = getSkillsCatalogKey(projectPath);
const requestId = ++skillsCatalogRequestSeq;
latestSkillsCatalogRequestByKey.set(requestKey, requestId);
set((prev) => {
const nextLoadingByProjectPath = {
...prev.skillsCatalogLoadingByProjectPath,
[requestKey]: true,
};
return {
skillsCatalogLoadingByProjectPath: nextLoadingByProjectPath,
skillsCatalogErrorByProjectPath: {
...prev.skillsCatalogErrorByProjectPath,
[requestKey]: null,
},
skillsLoading: hasAnyLoading(nextLoadingByProjectPath),
skillsError: null,
};
});
try {
const skills = await api.skills.list(projectPath);
if (latestSkillsCatalogRequestByKey.get(requestKey) !== requestId) {
return;
}
set((prev) => ({
skillsLoading: false,
skillsCatalogLoadingByProjectPath: {
...prev.skillsCatalogLoadingByProjectPath,
[requestKey]: false,
},
skillsCatalogErrorByProjectPath: {
...prev.skillsCatalogErrorByProjectPath,
[requestKey]: null,
},
skillsLoading: hasAnyLoading({
...prev.skillsCatalogLoadingByProjectPath,
[requestKey]: false,
}),
skillsError: null,
skillsUserCatalog: skills.filter((skill) => skill.scope === 'user'),
skillsProjectCatalogByProjectPath: projectPath
@ -332,31 +385,62 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
: prev.skillsProjectCatalogByProjectPath,
}));
} catch (err) {
set({
skillsLoading: false,
skillsError: err instanceof Error ? err.message : 'Failed to load skills',
});
if (latestSkillsCatalogRequestByKey.get(requestKey) !== requestId) {
return;
}
const message = err instanceof Error ? err.message : 'Failed to load skills';
set((prev) => ({
skillsCatalogLoadingByProjectPath: {
...prev.skillsCatalogLoadingByProjectPath,
[requestKey]: false,
},
skillsCatalogErrorByProjectPath: {
...prev.skillsCatalogErrorByProjectPath,
[requestKey]: message,
},
skillsLoading: hasAnyLoading({
...prev.skillsCatalogLoadingByProjectPath,
[requestKey]: false,
}),
skillsError: message,
}));
}
},
fetchSkillDetail: async (skillId: string, projectPath?: string) => {
if (!api.skills) return;
const requestId = ++skillsDetailRequestSeq;
latestSkillsDetailRequestById.set(skillId, requestId);
set((prev) => ({
skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: true },
skillsDetailErrorById: { ...prev.skillsDetailErrorById, [skillId]: null },
}));
try {
const detail = await api.skills.getDetail(skillId, projectPath);
if (latestSkillsDetailRequestById.get(skillId) !== requestId) {
return;
}
set((prev) => ({
skillsDetailsById: { ...prev.skillsDetailsById, [skillId]: detail },
skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: false },
skillsDetailErrorById: { ...prev.skillsDetailErrorById, [skillId]: null },
}));
} catch {
} catch (err) {
if (latestSkillsDetailRequestById.get(skillId) !== requestId) {
return;
}
const message = err instanceof Error ? err.message : 'Failed to load skill details';
set((prev) => ({
skillsDetailsById: { ...prev.skillsDetailsById, [skillId]: null },
skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: false },
skillsDetailErrorById: { ...prev.skillsDetailErrorById, [skillId]: message },
}));
throw err;
}
},

View file

@ -50,6 +50,7 @@ export type {
SkillReviewAction,
SkillReviewFileChange,
SkillReviewPreview,
SkillReviewSummary,
SkillSaveResult,
SkillScope,
SkillSourceType,

View file

@ -85,9 +85,12 @@ export interface SkillDraftTemplateInput {
invocationMode: SkillInvocationMode;
license: string;
compatibility: string;
whenToUse: string;
steps: string;
notes: string;
}
export type SkillReviewAction = 'create' | 'update';
export type SkillReviewAction = 'create' | 'update' | 'delete';
export interface SkillReviewFileChange {
relativePath: string;
@ -98,10 +101,19 @@ export interface SkillReviewFileChange {
isBinary: boolean;
}
export interface SkillReviewSummary {
created: number;
updated: number;
deleted: number;
binary: number;
}
export interface SkillReviewPreview {
planId: string;
targetSkillDir: string;
changes: SkillReviewFileChange[];
warnings: string[];
summary: SkillReviewSummary;
}
export interface SkillUpsertRequest {
@ -111,6 +123,7 @@ export interface SkillUpsertRequest {
folderName: string;
existingSkillId?: string;
files: SkillDraftFile[];
reviewPlanId?: string;
}
export interface SkillImportRequest {
@ -119,6 +132,7 @@ export interface SkillImportRequest {
rootKind: SkillRootKind;
projectPath?: string;
folderName?: string;
reviewPlanId?: string;
}
export interface SkillDeleteRequest {

View file

@ -0,0 +1,69 @@
import { describe, expect, it, vi } from 'vitest';
import {
initializeSkillsHandlers,
registerSkillsHandlers,
removeSkillsHandlers,
} from '@main/ipc/skills';
import { SKILLS_APPLY_UPSERT, SKILLS_LIST } from '@preload/constants/ipcChannels';
describe('skills IPC handlers', () => {
it('returns a validation error when applyUpsert has no request payload', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
const ipcMain = {
handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
handlers.set(channel, handler);
}),
removeHandler: vi.fn(),
};
initializeSkillsHandlers(
{ list: vi.fn(), getDetail: vi.fn() } as any,
{ previewUpsert: vi.fn(), applyUpsert: vi.fn() } as any,
{ start: vi.fn(), stop: vi.fn() } as any
);
registerSkillsHandlers(ipcMain as any);
const result = (await handlers.get(SKILLS_APPLY_UPSERT)?.({})) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain('request is required');
consoleErrorSpy.mockRestore();
removeSkillsHandlers(ipcMain as any);
});
it('returns successful list results from the catalog service', async () => {
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
const ipcMain = {
handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
handlers.set(channel, handler);
}),
removeHandler: vi.fn(),
};
initializeSkillsHandlers(
{
list: vi.fn().mockResolvedValue([{ id: 'skill-1' }]),
getDetail: vi.fn(),
} as any,
{ previewUpsert: vi.fn(), applyUpsert: vi.fn() } as any,
{ start: vi.fn(), stop: vi.fn() } as any
);
registerSkillsHandlers(ipcMain as any);
const result = (await handlers.get(SKILLS_LIST)?.({}, '/tmp/project')) as {
success: boolean;
data?: Array<{ id: string }>;
};
expect(result.success).toBe(true);
expect(result.data).toEqual([{ id: 'skill-1' }]);
removeSkillsHandlers(ipcMain as any);
});
});

View file

@ -0,0 +1,44 @@
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 { SkillImportService } from '@main/services/extensions/skills/SkillImportService';
describe('SkillImportService', () => {
const createdDirs: string[] = [];
afterEach(async () => {
await Promise.all(createdDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it('skips hidden entries and reports the warning', async () => {
const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-import-'));
createdDirs.push(sourceDir);
await fs.writeFile(path.join(sourceDir, 'SKILL.md'), '# Demo', 'utf8');
await fs.writeFile(path.join(sourceDir, '.DS_Store'), 'hidden', 'utf8');
await fs.mkdir(path.join(sourceDir, '.cache'), { recursive: true });
await fs.writeFile(path.join(sourceDir, '.cache', 'ignore.txt'), 'hidden', 'utf8');
const inspection = await new SkillImportService().inspectSourceDir(sourceDir);
expect(inspection.files.map((file) => file.relativePath)).toEqual(['SKILL.md']);
expect(inspection.hiddenEntriesSkipped).toBe(2);
expect(inspection.warnings).toContain('Hidden files and folders were skipped during import.');
});
it('rejects symbolic links in the import source', async () => {
const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-import-'));
createdDirs.push(sourceDir);
await fs.writeFile(path.join(sourceDir, 'SKILL.md'), '# Demo', 'utf8');
await fs.writeFile(path.join(sourceDir, 'real.txt'), 'hello', 'utf8');
await fs.symlink(path.join(sourceDir, 'real.txt'), path.join(sourceDir, 'linked.txt'));
await expect(new SkillImportService().inspectSourceDir(sourceDir)).rejects.toThrow(
'symbolic links'
);
});
});

View file

@ -0,0 +1,51 @@
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 { SkillPlanService } from '@main/services/extensions/skills/SkillPlanService';
describe('SkillPlanService', () => {
const createdDirs: string[] = [];
afterEach(async () => {
await Promise.all(createdDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it('builds a canonical upsert plan including managed deletions', async () => {
const service = new SkillPlanService();
const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-plan-'));
createdDirs.push(targetDir);
await fs.mkdir(path.join(targetDir, 'scripts'), { recursive: true });
await fs.writeFile(path.join(targetDir, 'SKILL.md'), '# old', 'utf8');
await fs.writeFile(path.join(targetDir, 'scripts', 'README.md'), 'old script', 'utf8');
await fs.writeFile(path.join(targetDir, 'notes.txt'), 'keep me', 'utf8');
const plan = await service.buildUpsertPlan(targetDir, [
{ relativePath: 'SKILL.md', content: '# new' },
{ relativePath: 'references/README.md', content: '# refs' },
]);
expect(plan.preview.summary).toEqual({
created: 1,
updated: 1,
deleted: 1,
binary: 0,
});
expect(
Object.fromEntries(plan.preview.changes.map((change) => [change.relativePath, change.action]))
).toEqual({
'SKILL.md': 'update',
'references/README.md': 'create',
'scripts/README.md': 'delete',
});
expect(plan.preview.warnings).toContain(
'1 managed file will be removed to match this reviewed plan.'
);
expect(plan.preview.warnings).toContain(
'Existing files outside the managed skill set will be kept as-is.'
);
});
});

View file

@ -0,0 +1,107 @@
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 { SkillPlanService } from '@main/services/extensions/skills/SkillPlanService';
import { SkillScaffoldService } from '@main/services/extensions/skills/SkillScaffoldService';
import { SkillsCatalogService } from '@main/services/extensions/skills/SkillsCatalogService';
import { SkillsMutationService } from '@main/services/extensions/skills/SkillsMutationService';
import type { ResolvedSkillRoot } from '@main/services/extensions/skills/SkillRootsResolver';
function createResolver(rootPath: string) {
return {
resolve(projectPath?: string): ResolvedSkillRoot[] {
return [
{
scope: 'project',
rootKind: 'claude',
projectRoot: projectPath ?? rootPath,
rootPath,
},
];
},
};
}
describe('SkillsMutationService', () => {
const createdDirs: string[] = [];
afterEach(async () => {
await Promise.all(createdDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it('applies the reviewed plan and deletes obsolete managed files', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-mutation-'));
createdDirs.push(projectRoot);
const skillsRoot = path.join(projectRoot, '.claude', 'skills');
const skillDir = path.join(skillsRoot, 'demo');
await fs.mkdir(path.join(skillDir, 'scripts'), { recursive: true });
await fs.writeFile(path.join(skillDir, 'SKILL.md'), '# old', 'utf8');
await fs.writeFile(path.join(skillDir, 'scripts', 'README.md'), 'old script', 'utf8');
const resolver = createResolver(skillsRoot);
const mutationService = new SkillsMutationService(
resolver as any,
new SkillsCatalogService(resolver as any),
new SkillScaffoldService(resolver as any),
undefined,
new SkillPlanService()
);
const request = {
scope: 'project' as const,
rootKind: 'claude' as const,
projectPath: projectRoot,
folderName: 'demo',
existingSkillId: skillDir,
files: [{ relativePath: 'SKILL.md', content: '# updated' }],
};
const preview = await mutationService.previewUpsert(request);
await mutationService.applyUpsert({ ...request, reviewPlanId: preview.planId });
await expect(fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf8')).resolves.toBe('# updated');
await expect(fs.stat(path.join(skillDir, 'scripts', 'README.md'))).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('rejects apply when the reviewed plan is stale', async () => {
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-mutation-'));
createdDirs.push(projectRoot);
const skillsRoot = path.join(projectRoot, '.claude', 'skills');
const skillDir = path.join(skillsRoot, 'demo');
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, 'SKILL.md'), '# old', 'utf8');
const resolver = createResolver(skillsRoot);
const mutationService = new SkillsMutationService(
resolver as any,
new SkillsCatalogService(resolver as any),
new SkillScaffoldService(resolver as any),
undefined,
new SkillPlanService()
);
const request = {
scope: 'project' as const,
rootKind: 'claude' as const,
projectPath: projectRoot,
folderName: 'demo',
existingSkillId: skillDir,
files: [{ relativePath: 'SKILL.md', content: '# updated' }],
};
const preview = await mutationService.previewUpsert(request);
await fs.writeFile(path.join(skillDir, 'SKILL.md'), '# changed after review', 'utf8');
await expect(
mutationService.applyUpsert({ ...request, reviewPlanId: preview.planId })
).rejects.toThrow('changed after review');
});
});

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import {
buildSkillTemplate,
readSkillTemplateInput,
} from '../../../../../src/renderer/components/extensions/skills/skillDraftUtils';
describe('skillDraftUtils', () => {
it('builds and parses structured sections for guided editing', () => {
const template = buildSkillTemplate({
name: 'Review Helper',
description: 'Helps with code review',
license: 'MIT',
compatibility: 'claude-code',
invocationMode: 'manual-only',
whenToUse: 'Use this when a PR needs review.',
steps: '1. Read the diff.\n2. Call out the biggest risk first.',
notes: '- Prefer concrete findings over summaries.',
});
const parsed = readSkillTemplateInput(template);
expect(parsed).toMatchObject({
name: 'Review Helper',
description: 'Helps with code review',
license: 'MIT',
compatibility: 'claude-code',
invocationMode: 'manual-only',
whenToUse: 'Use this when a PR needs review.',
steps: '1. Read the diff.\n2. Call out the biggest risk first.',
notes: '- Prefer concrete findings over summaries.',
});
});
});

View file

@ -41,7 +41,12 @@ vi.mock('../../../src/renderer/api', () => ({
import { api } from '../../../src/renderer/api';
import type { EnrichedPlugin, McpCatalogItem } from '../../../src/shared/types/extensions';
import type {
EnrichedPlugin,
McpCatalogItem,
SkillCatalogItem,
SkillDetail,
} from '../../../src/shared/types/extensions';
const makePlugin = (overrides: Partial<EnrichedPlugin>): EnrichedPlugin => ({
pluginId: 'test@marketplace',
@ -75,6 +80,42 @@ const makeMcpServer = (overrides: Partial<McpCatalogItem>): McpCatalogItem => ({
...overrides,
});
const makeSkill = (overrides: Partial<SkillCatalogItem>): SkillCatalogItem => ({
id: '/tmp/skills/demo',
sourceType: 'filesystem',
name: 'Demo Skill',
description: 'Helps with demo work',
folderName: 'demo',
scope: 'user',
rootKind: 'claude',
projectRoot: null,
discoveryRoot: '/tmp/skills',
skillDir: '/tmp/skills/demo',
skillFile: '/tmp/skills/demo/SKILL.md',
metadata: {},
invocationMode: 'auto',
flags: {
hasScripts: false,
hasReferences: false,
hasAssets: false,
},
isValid: true,
issues: [],
modifiedAt: 1,
...overrides,
});
const makeSkillDetail = (overrides: Partial<SkillDetail>): SkillDetail => ({
item: makeSkill({ id: '/tmp/skills/demo', skillDir: '/tmp/skills/demo' }),
body: 'body',
rawContent: '# Demo',
rawFrontmatter: null,
referencesFiles: [],
scriptFiles: [],
assetFiles: [],
...overrides,
});
describe('extensionsSlice', () => {
let store: TestStore;
@ -296,4 +337,64 @@ describe('extensionsSlice', () => {
expect(store.getState().mcpInstallProgress['test-id']).toBe('success');
});
});
describe('skills state hardening', () => {
it('ignores stale catalog responses for the same project key', async () => {
let resolveFirst: ((value: SkillCatalogItem[]) => void) | null = null;
const firstPromise = new Promise<SkillCatalogItem[]>((resolve) => {
resolveFirst = resolve;
});
const secondResult = [
makeSkill({
id: '/tmp/project/.claude/skills/newer',
skillDir: '/tmp/project/.claude/skills/newer',
skillFile: '/tmp/project/.claude/skills/newer/SKILL.md',
scope: 'project',
projectRoot: '/tmp/project',
discoveryRoot: '/tmp/project/.claude/skills',
name: 'Newer Skill',
}),
];
(api.skills!.list as ReturnType<typeof vi.fn>)
.mockImplementationOnce(() => firstPromise)
.mockResolvedValueOnce(secondResult);
const firstFetch = store.getState().fetchSkillsCatalog('/tmp/project');
const secondFetch = store.getState().fetchSkillsCatalog('/tmp/project');
await secondFetch;
resolveFirst?.([
makeSkill({
id: '/tmp/project/.claude/skills/older',
skillDir: '/tmp/project/.claude/skills/older',
skillFile: '/tmp/project/.claude/skills/older/SKILL.md',
scope: 'project',
projectRoot: '/tmp/project',
discoveryRoot: '/tmp/project/.claude/skills',
name: 'Older Skill',
}),
]);
await firstFetch;
expect(store.getState().skillsProjectCatalogByProjectPath['/tmp/project']).toEqual(
secondResult
);
});
it('keeps the previous detail cache when a detail fetch fails', async () => {
const cachedDetail = makeSkillDetail();
store.setState({
skillsDetailsById: { [cachedDetail.item.id]: cachedDetail },
});
(api.skills!.getDetail as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('detail fail'));
await expect(
store.getState().fetchSkillDetail(cachedDetail.item.id, '/tmp/project')
).rejects.toThrow('detail fail');
expect(store.getState().skillsDetailsById[cachedDetail.item.id]).toEqual(cachedDetail);
expect(store.getState().skillsDetailErrorById[cachedDetail.item.id]).toBe('detail fail');
});
});
});