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", "@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",

View file

@ -101,6 +101,9 @@ importers:
'@fastify/static': '@fastify/static':
specifier: ^9.0.0 specifier: ^9.0.0
version: 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': '@radix-ui/react-checkbox':
specifier: ^1.3.3 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) 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': '@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} 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': '@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies: peerDependencies:
@ -7995,6 +8011,20 @@ snapshots:
'@radix-ui/primitive@1.1.3': {} '@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)': '@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: 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) '@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 { SkillsCatalogService } from './skills/SkillsCatalogService';
export { SkillScaffoldService } from './skills/SkillScaffoldService'; export { SkillScaffoldService } from './skills/SkillScaffoldService';
export { SkillImportService } from './skills/SkillImportService'; export { SkillImportService } from './skills/SkillImportService';
export { SkillPlanService } from './skills/SkillPlanService';
export { SkillReviewService } from './skills/SkillReviewService'; export { SkillReviewService } from './skills/SkillReviewService';
export { SkillsMutationService } from './skills/SkillsMutationService'; export { SkillsMutationService } from './skills/SkillsMutationService';
export { SkillsWatcherService } from './skills/SkillsWatcherService'; export { SkillsWatcherService } from './skills/SkillsWatcherService';

View file

@ -13,6 +13,15 @@ export interface ImportedSkillSourceFile {
isBinary: boolean; 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 { export class SkillImportService {
constructor(private readonly scanner = new SkillScanner()) {} constructor(private readonly scanner = new SkillScanner()) {}
@ -36,11 +45,11 @@ export class SkillImportService {
return normalizedSourceDir; return normalizedSourceDir;
} }
async readSourceFiles(sourceDir: string): Promise<ImportedSkillSourceFile[]> { async inspectSourceDir(sourceDir: string): Promise<SkillImportInspection> {
const entries = await this.walkDirectory(sourceDir); const normalizedSourceDir = await this.validateSourceDir(sourceDir);
return Promise.all( const walked = await this.walkDirectory(normalizedSourceDir);
entries.map(async (absolutePath) => { const files = await Promise.all(
const relativePath = path.relative(sourceDir, absolutePath).replace(/\\/g, '/'); walked.files.map(async ({ absolutePath, relativePath }) => {
const binary = await isBinaryFile(absolutePath); const binary = await isBinaryFile(absolutePath);
return { return {
relativePath, 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( async writeImportedFiles(
@ -67,17 +101,57 @@ export class SkillImportService {
} }
} }
private async walkDirectory(rootDir: string): Promise<string[]> { private async walkDirectory(
const dirEntries = await fs.readdir(rootDir, { withFileTypes: true }); rootDir: string
const results = await Promise.all( ): Promise<{
dirEntries.map(async (entry) => { files: Array<{ absolutePath: string; relativePath: string }>;
const fullPath = path.join(rootDir, entry.name); hiddenEntriesSkipped: number;
if (entry.isDirectory()) { }> {
return this.walkDirectory(fullPath); 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];
}) const fullPath = path.join(currentDir, entry.name);
); if (entry.isSymbolicLink()) {
return results.flat().sort((a, b) => a.localeCompare(b)); 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 { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation';
import { SkillImportService } from './SkillImportService'; import { SkillImportService } from './SkillImportService';
import { SkillReviewService } from './SkillReviewService'; import { SkillPlanService } from './SkillPlanService';
import { SkillScaffoldService } from './SkillScaffoldService'; import { SkillScaffoldService } from './SkillScaffoldService';
import { SkillRootsResolver } from './SkillRootsResolver'; import { SkillRootsResolver } from './SkillRootsResolver';
import { SkillsCatalogService } from './SkillsCatalogService'; import { SkillsCatalogService } from './SkillsCatalogService';
@ -24,7 +24,7 @@ export class SkillsMutationService {
private readonly catalogService = new SkillsCatalogService(), private readonly catalogService = new SkillsCatalogService(),
private readonly scaffoldService = new SkillScaffoldService(rootsResolver), private readonly scaffoldService = new SkillScaffoldService(rootsResolver),
private readonly importService = new SkillImportService(), private readonly importService = new SkillImportService(),
private readonly reviewService = new SkillReviewService() private readonly planService = new SkillPlanService()
) {} ) {}
async previewUpsert(request: SkillUpsertRequest): Promise<SkillReviewPreview> { async previewUpsert(request: SkillUpsertRequest): Promise<SkillReviewPreview> {
@ -36,15 +36,15 @@ export class SkillsMutationService {
request.existingSkillId request.existingSkillId
); );
const files = this.scaffoldService.normalizeDraftFiles(request.files); const files = this.scaffoldService.normalizeDraftFiles(request.files);
const changes = await this.reviewService.buildTextChanges(targetSkillDir, files); const plan = await this.planService.buildUpsertPlan(targetSkillDir, files);
return { return plan.preview;
targetSkillDir,
changes,
warnings: [],
};
} }
async applyUpsert(request: SkillUpsertRequest): Promise<SkillDetail | null> { 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( const targetSkillDir = await this.scaffoldService.resolveUpsertTarget(
request.scope, request.scope,
request.rootKind, request.rootKind,
@ -53,30 +53,33 @@ export class SkillsMutationService {
request.existingSkillId request.existingSkillId
); );
const files = this.scaffoldService.normalizeDraftFiles(request.files); 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); return this.catalogService.getDetail(targetSkillDir, request.projectPath);
} }
async previewImport(request: SkillImportRequest): Promise<SkillReviewPreview> { async previewImport(request: SkillImportRequest): Promise<SkillReviewPreview> {
const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request); const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request);
const sourceFiles = await this.importService.readSourceFiles(sourceDir); const inspection = await this.importService.inspectSourceDir(sourceDir);
const changes = await this.reviewService.buildImportChanges(targetSkillDir, sourceFiles); const plan = await this.planService.buildImportPlan(targetSkillDir, inspection.files);
const warnings = changes.some((change) => change.isBinary)
? ['This import includes binary files. Binary files will be copied as-is.']
: [];
return { return {
targetSkillDir, ...plan.preview,
changes, warnings: [...new Set([...inspection.warnings, ...plan.preview.warnings])],
warnings,
}; };
} }
async applyImport(request: SkillImportRequest): Promise<SkillDetail | null> { 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 { sourceDir, targetSkillDir } = await this.resolveImportTarget(request);
const sourceFiles = await this.importService.readSourceFiles(sourceDir); const inspection = await this.importService.inspectSourceDir(sourceDir);
await this.importService.writeImportedFiles(targetSkillDir, sourceFiles); 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); return this.catalogService.getDetail(targetSkillDir, request.projectPath);
} }
@ -133,4 +136,12 @@ export class SkillsMutationService {
} }
return normalizedSkillDir; 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 { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -21,6 +21,7 @@ import {
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { ApiKeysPanel } from './apikeys/ApiKeysPanel'; import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog'; import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
import { McpServersPanel } from './mcp/McpServersPanel'; import { McpServersPanel } from './mcp/McpServersPanel';
import { PluginsPanel } from './plugins/PluginsPanel'; import { PluginsPanel } from './plugins/PluginsPanel';
@ -57,6 +58,39 @@ export const ExtensionStoreView = (): React.JSX.Element => {
() => projects.find((project) => project.id === extensionsTabProjectId)?.name ?? null, () => projects.find((project) => project.id === extensionsTabProjectId)?.name ?? null,
[extensionsTabProjectId, projects] [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 // Fetch plugin catalog on mount
useEffect(() => { useEffect(() => {
@ -102,15 +136,15 @@ export const ExtensionStoreView = (): React.JSX.Element => {
} }
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <TooltipProvider>
<div className="flex-1 overflow-y-auto"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Header */} <div className="flex-1 overflow-y-auto">
<div className="flex items-center justify-between border-b border-border px-6 py-4"> {/* Header */}
<div className="flex items-center gap-3"> <div className="flex items-center justify-between px-6 py-4">
<Puzzle className="size-5 text-text-muted" /> <div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-text">Extensions</h1> <Puzzle className="size-5 text-text-muted" />
</div> <h1 className="text-lg font-semibold text-text">Extensions</h1>
<TooltipProvider> </div>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleRefresh} disabled={isRefreshing}> <Button variant="ghost" size="icon" onClick={handleRefresh} disabled={isRefreshing}>
@ -119,116 +153,109 @@ export const ExtensionStoreView = (): React.JSX.Element => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Refresh catalog</TooltipContent> <TooltipContent>Refresh catalog</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </div>
</div>
{/* Sub-tabs */} {/* Sub-tabs */}
<div className="px-6 py-4"> <div className="px-6 py-4">
{/* CLI not installed warning */} {/* CLI not installed warning */}
{!cliInstalled && ( {!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"> <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" /> <AlertTriangle className="size-4 shrink-0" />
Claude CLI is required to install or uninstall extensions. Install it from Settings. Claude CLI is required to install or uninstall extensions. Install it from Settings.
</div> </div>
)} )}
{/* Active sessions warning */} {/* Active sessions warning */}
{hasOngoingSessions && ( {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"> <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" /> <Info className="size-4 shrink-0" />
Running sessions won&apos;t pick up extension changes until restarted. Running sessions won&apos;t pick up extension changes until restarted.
</div> </div>
)} )}
<Tabs <Tabs
value={tabState.activeSubTab} value={tabState.activeSubTab}
onValueChange={(v) => onValueChange={(v) =>
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'skills' | 'api-keys') tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'skills' | 'api-keys')
} }
> >
<div className="mb-4 flex items-center justify-between"> <div className="-mx-6 flex items-end justify-between border-b border-border px-6">
<TabsList> <TabsList className="gap-1 rounded-b-none">
<TabsTrigger value="plugins" className="gap-1.5"> {subTabs.map((subTab) => (
<Puzzle className="size-3.5" /> <ExtensionsSubTabTrigger
Plugins key={subTab.value}
</TabsTrigger> value={subTab.value}
<TabsTrigger value="mcp-servers" className="gap-1.5"> label={subTab.label}
<Server className="size-3.5" /> icon={subTab.icon}
MCP Servers description={subTab.description}
</TabsTrigger> />
<TabsTrigger value="skills" className="gap-1.5"> ))}
<BookOpen className="size-3.5" /> </TabsList>
Skills {tabState.activeSubTab === 'mcp-servers' && (
</TabsTrigger> <Button
<TabsTrigger value="api-keys" className="gap-1.5"> variant="outline"
<Key className="size-3.5" /> size="sm"
API Keys onClick={() => setCustomMcpDialogOpen(true)}
</TabsTrigger> className="whitespace-nowrap"
</TabsList> >
{tabState.activeSubTab === 'mcp-servers' && ( <Plus className="mr-1 size-3.5" />
<Button Add Custom
variant="outline" </Button>
size="sm" )}
onClick={() => setCustomMcpDialogOpen(true)} </div>
className="whitespace-nowrap"
>
<Plus className="mr-1 size-3.5" />
Add Custom
</Button>
)}
</div>
<TabsContent value="plugins"> <TabsContent value="plugins" className="mt-0 pt-4">
<PluginsPanel <PluginsPanel
pluginFilters={tabState.pluginFilters} pluginFilters={tabState.pluginFilters}
pluginSort={tabState.pluginSort} pluginSort={tabState.pluginSort}
selectedPluginId={tabState.selectedPluginId} selectedPluginId={tabState.selectedPluginId}
updatePluginSearch={tabState.updatePluginSearch} updatePluginSearch={tabState.updatePluginSearch}
toggleCategory={tabState.toggleCategory} toggleCategory={tabState.toggleCategory}
toggleCapability={tabState.toggleCapability} toggleCapability={tabState.toggleCapability}
toggleInstalledOnly={tabState.toggleInstalledOnly} toggleInstalledOnly={tabState.toggleInstalledOnly}
setSelectedPluginId={tabState.setSelectedPluginId} setSelectedPluginId={tabState.setSelectedPluginId}
clearFilters={tabState.clearFilters} clearFilters={tabState.clearFilters}
hasActiveFilters={tabState.hasActiveFilters} hasActiveFilters={tabState.hasActiveFilters}
setPluginSort={tabState.setPluginSort} setPluginSort={tabState.setPluginSort}
/> />
</TabsContent> </TabsContent>
<TabsContent value="mcp-servers"> <TabsContent value="mcp-servers" className="mt-0 pt-4">
<McpServersPanel <McpServersPanel
mcpSearchQuery={tabState.mcpSearchQuery} mcpSearchQuery={tabState.mcpSearchQuery}
mcpSearch={tabState.mcpSearch} mcpSearch={tabState.mcpSearch}
mcpSearchResults={tabState.mcpSearchResults} mcpSearchResults={tabState.mcpSearchResults}
mcpSearchLoading={tabState.mcpSearchLoading} mcpSearchLoading={tabState.mcpSearchLoading}
mcpSearchWarnings={tabState.mcpSearchWarnings} mcpSearchWarnings={tabState.mcpSearchWarnings}
selectedMcpServerId={tabState.selectedMcpServerId} selectedMcpServerId={tabState.selectedMcpServerId}
setSelectedMcpServerId={tabState.setSelectedMcpServerId} setSelectedMcpServerId={tabState.setSelectedMcpServerId}
/> />
</TabsContent> </TabsContent>
<TabsContent value="api-keys"> <TabsContent value="api-keys" className="mt-0 pt-4">
<ApiKeysPanel /> <ApiKeysPanel />
</TabsContent> </TabsContent>
<TabsContent value="skills"> <TabsContent value="skills" className="mt-0 pt-4">
<SkillsPanel <SkillsPanel
projectPath={projectPath} projectPath={projectPath}
projectLabel={projectLabel} projectLabel={projectLabel}
skillsSearchQuery={tabState.skillsSearchQuery} skillsSearchQuery={tabState.skillsSearchQuery}
setSkillsSearchQuery={tabState.setSkillsSearchQuery} setSkillsSearchQuery={tabState.setSkillsSearchQuery}
skillsSort={tabState.skillsSort} skillsSort={tabState.skillsSort}
setSkillsSort={tabState.setSkillsSort} setSkillsSort={tabState.setSkillsSort}
selectedSkillId={tabState.selectedSkillId} selectedSkillId={tabState.selectedSkillId}
setSelectedSkillId={tabState.setSelectedSkillId} setSelectedSkillId={tabState.setSelectedSkillId}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{/* Custom MCP server dialog (lifted to store view level) */} {/* Custom MCP server dialog (lifted to store view level) */}
<CustomMcpServerDialog <CustomMcpServerDialog
open={customMcpDialogOpen} open={customMcpDialogOpen}
onClose={() => setCustomMcpDialogOpen(false)} onClose={() => setCustomMcpDialogOpen(false)}
/> />
</div>
</div> </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); 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)] ${ 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-surface-raised/10' : 'bg-transparent' baseStriped ? 'bg-white/[0.045]' : 'bg-white/[0.015]'
} ${smStriped ? 'sm:bg-surface-raised/10' : 'sm:bg-transparent'} ${ } ${smStriped ? 'sm:bg-white/[0.045]' : 'sm:bg-white/[0.015]'} ${
xlStriped ? 'xl:bg-surface-raised/10' : 'xl:bg-transparent' 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' 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 { interface SkillCodeEditorProps {
value: string; value: string;
onChange: (value: string) => void; 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 containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null); const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
@ -72,13 +79,27 @@ export const SkillCodeEditor = ({ value, onChange }: SkillCodeEditorProps): Reac
}); });
viewRef.current = view; 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 () => { 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(); view.destroy();
viewRef.current = null; viewRef.current = null;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps -- create editor once per mount // eslint-disable-next-line react-hooks/exhaustive-deps -- create editor once per mount
}, []); }, [onScroll, scrollRef]);
useEffect(() => { useEffect(() => {
const view = viewRef.current; 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 { api } from '@renderer/api';
import { CodeBlockViewer } from '@renderer/components/chat/viewers/CodeBlockViewer'; import { CodeBlockViewer } from '@renderer/components/chat/viewers/CodeBlockViewer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; 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 { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { import {
@ -34,18 +44,29 @@ export const SkillDetailDialog = ({
}: SkillDetailDialogProps): React.JSX.Element => { }: SkillDetailDialogProps): React.JSX.Element => {
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail); const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
const deleteSkill = useStore((s) => s.deleteSkill); const deleteSkill = useStore((s) => s.deleteSkill);
const skillsMutationLoading = useStore((s) => s.skillsMutationLoading);
const detail = useStore((s) => (skillId ? s.skillsDetailsById[skillId] : undefined)); const detail = useStore((s) => (skillId ? s.skillsDetailsById[skillId] : undefined));
const loading = useStore((s) => const loading = useStore((s) =>
skillId ? (s.skillsDetailLoadingById[skillId] ?? false) : false 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(() => { useEffect(() => {
if (!open || !skillId) return; if (!open || !skillId) return;
if (detail === undefined) { void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined);
void fetchSkillDetail(skillId, projectPath ?? undefined); }, [fetchSkillDetail, open, projectPath, skillId]);
useEffect(() => {
if (!open) {
setDeleteError(null);
setDeleteLoading(false);
setDeleteConfirmOpen(false);
} }
}, [detail, fetchSkillDetail, open, projectPath, skillId]); }, [open]);
const item = detail?.item; const item = detail?.item;
@ -53,16 +74,32 @@ export const SkillDetailDialog = ({
return `.${rootKind}`; 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> { async function handleDelete(): Promise<void> {
if (!item) return; if (!item) return;
const confirmed = window.confirm(`Delete skill "${item.name}"? It will be moved to Trash.`); setDeleteLoading(true);
if (!confirmed) return; setDeleteError(null);
try {
await deleteSkill({ await deleteSkill({
skillId: item.id, skillId: item.id,
projectPath: projectPath ?? undefined, projectPath: projectPath ?? undefined,
}); });
onDeleted(); setDeleteConfirmOpen(false);
onDeleted();
} catch (error) {
setDeleteError(error instanceof Error ? error.message : 'Failed to delete skill');
} finally {
setDeleteLoading(false);
}
} }
return ( return (
@ -79,7 +116,24 @@ export const SkillDetailDialog = ({
<p className="text-sm text-text-muted">Loading skill details...</p> <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"> <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. Unable to load this skill.
</div> </div>
@ -87,17 +141,27 @@ export const SkillDetailDialog = ({
{!loading && detail && item && ( {!loading && detail && item && (
<div className="space-y-4"> <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"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{item.scope}</Badge> <Badge variant="outline">{formatScopeLabel(item.scope)}</Badge>
<Badge variant="outline">{formatRootKind(item.rootKind)}</Badge> <Badge variant="outline">Stored in {formatRootKind(item.rootKind)}</Badge>
<Badge variant="secondary">{item.invocationMode}</Badge> <Badge variant="secondary">
{item.flags.hasScripts && <Badge variant="destructive">scripts</Badge>} {item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'}
{item.flags.hasReferences && <Badge variant="secondary">references</Badge>} </Badge>
{item.flags.hasAssets && <Badge variant="secondary">assets</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> </div>
{item.issues.length > 0 && ( {item.issues.length > 0 && (
<div className="space-y-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-4"> <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) => ( {item.issues.map((issue, index) => (
<div <div
key={`${issue.code}-${index}`} key={`${issue.code}-${index}`}
@ -110,6 +174,35 @@ export const SkillDetailDialog = ({
</div> </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"> <div className="flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={onEdit}> <Button variant="secondary" size="sm" onClick={onEdit}>
<Pencil className="mr-1.5 size-3.5" /> <Pencil className="mr-1.5 size-3.5" />
@ -118,27 +211,11 @@ export const SkillDetailDialog = ({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => void api.showInFolder(item.skillFile)} onClick={() => setDeleteConfirmOpen(true)}
> disabled={deleteLoading}
<FolderOpen className="mr-1.5 size-3.5" />
Open Folder
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void api.openPath(item.skillFile, projectPath ?? undefined)}
>
<ExternalLink className="mr-1.5 size-3.5" />
Open SKILL.md
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void handleDelete()}
disabled={skillsMutationLoading}
> >
<Trash2 className="mr-1.5 size-3.5" /> <Trash2 className="mr-1.5 size-3.5" />
Delete {deleteLoading ? 'Deleting...' : 'Delete'}
</Button> </Button>
</div> </div>
@ -153,15 +230,9 @@ export const SkillDetailDialog = ({
</div> </div>
<div className="space-y-4"> <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="rounded-lg border border-border p-3 text-sm text-text-secondary">
<div className="space-y-2"> <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> <p className="break-all text-xs text-text-muted">{item.skillDir}</p>
</div> </div>
@ -198,11 +269,61 @@ export const SkillDetailDialog = ({
</div> </div>
)} )}
</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> </div>
</div> </div>
)} )}
</DialogContent> </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> </Dialog>
); );
}; };

View file

@ -8,7 +8,6 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
@ -21,6 +20,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { Textarea } from '@renderer/components/ui/textarea';
import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { FileSearch, RotateCcw, X } from 'lucide-react'; import { FileSearch, RotateCcw, X } from 'lucide-react';
@ -29,12 +30,11 @@ import { SkillReviewDialog } from './SkillReviewDialog';
import { import {
buildSkillDraftFiles, buildSkillDraftFiles,
buildSkillTemplate, buildSkillTemplate,
readSkillTemplateInput, readSkillTemplateContent,
updateSkillTemplateFrontmatter, updateSkillTemplateFrontmatter,
} from './skillDraftUtils'; } from './skillDraftUtils';
import type { import type {
SkillCatalogItem,
SkillDetail, SkillDetail,
SkillInvocationMode, SkillInvocationMode,
SkillReviewPreview, SkillReviewPreview,
@ -60,6 +60,16 @@ function parseInitialDescription(detail: SkillDetail | null): string {
return detail?.item.description ?? ''; 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 = ({ export const SkillEditorDialog = ({
open, open,
mode, mode,
@ -70,11 +80,10 @@ export const SkillEditorDialog = ({
onSaved, onSaved,
}: SkillEditorDialogProps): React.JSX.Element => { }: SkillEditorDialogProps): React.JSX.Element => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const editorScrollRef = useRef<HTMLElement | null>(null);
const rawContentRef = useRef(''); const rawContentRef = useRef('');
const previewSkillUpsert = useStore((s) => s.previewSkillUpsert); const previewSkillUpsert = useStore((s) => s.previewSkillUpsert);
const applySkillUpsert = useStore((s) => s.applySkillUpsert); 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 [scope, setScope] = useState<'user' | 'project'>('user');
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude'); const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
@ -84,17 +93,31 @@ export const SkillEditorDialog = ({
const [license, setLicense] = useState(''); const [license, setLicense] = useState('');
const [compatibility, setCompatibility] = useState(''); const [compatibility, setCompatibility] = useState('');
const [invocationMode, setInvocationMode] = useState<SkillInvocationMode>('auto'); const [invocationMode, setInvocationMode] = useState<SkillInvocationMode>('auto');
const [whenToUse, setWhenToUse] = useState('');
const [steps, setSteps] = useState('');
const [notes, setNotes] = useState('');
const [includeScripts, setIncludeScripts] = useState(false); const [includeScripts, setIncludeScripts] = useState(false);
const [includeReferences, setIncludeReferences] = useState(false); const [includeReferences, setIncludeReferences] = useState(false);
const [includeAssets, setIncludeAssets] = useState(false); const [includeAssets, setIncludeAssets] = useState(false);
const [rawContent, setRawContent] = useState(''); const [rawContent, setRawContent] = useState('');
const [folderNameEdited, setFolderNameEdited] = useState(false);
const [customMarkdownDetected, setCustomMarkdownDetected] = useState(false);
const [manualRawEdit, setManualRawEdit] = useState(false); const [manualRawEdit, setManualRawEdit] = useState(false);
const [showAdvancedEditor, setShowAdvancedEditor] = useState(false);
const [splitRatio, setSplitRatio] = useState(0.52); const [splitRatio, setSplitRatio] = useState(0.52);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [reviewPreview, setReviewPreview] = useState<SkillReviewPreview | null>(null); const [reviewPreview, setReviewPreview] = useState<SkillReviewPreview | null>(null);
const [reviewOpen, setReviewOpen] = useState(false); 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<{ nextValues: Partial<{
name: string; name: string;
@ -102,6 +125,9 @@ export const SkillEditorDialog = ({
license: string; license: string;
compatibility: string; compatibility: string;
invocationMode: SkillInvocationMode; invocationMode: SkillInvocationMode;
whenToUse: string;
steps: string;
notes: string;
}> }>
) => { ) => {
const merged = { const merged = {
@ -110,17 +136,31 @@ export const SkillEditorDialog = ({
license, license,
compatibility, compatibility,
invocationMode, invocationMode,
whenToUse,
steps,
notes,
...nextValues, ...nextValues,
}; };
const nextRawContent = const nextRawContent =
mode === 'create' && !manualRawEdit !manualRawEdit && !customMarkdownDetected
? buildSkillTemplate(merged) ? buildSkillTemplate(merged)
: updateSkillTemplateFrontmatter(rawContentRef.current, merged); : updateSkillTemplateFrontmatter(rawContentRef.current, merged);
rawContentRef.current = nextRawContent; rawContentRef.current = nextRawContent;
setRawContent(nextRawContent); setRawContent(nextRawContent);
}, },
[compatibility, description, invocationMode, license, manualRawEdit, mode, name] [
compatibility,
description,
invocationMode,
license,
manualRawEdit,
customMarkdownDetected,
name,
notes,
steps,
whenToUse,
]
); );
useEffect(() => { useEffect(() => {
@ -135,6 +175,9 @@ export const SkillEditorDialog = ({
const nextLicense = item?.license ?? ''; const nextLicense = item?.license ?? '';
const nextCompatibility = item?.compatibility ?? ''; const nextCompatibility = item?.compatibility ?? '';
const nextInvocationMode = item?.invocationMode ?? 'auto'; 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 = const nextRawContent =
detail?.rawContent ?? detail?.rawContent ??
buildSkillTemplate({ buildSkillTemplate({
@ -143,12 +186,18 @@ export const SkillEditorDialog = ({
license: nextLicense, license: nextLicense,
compatibility: nextCompatibility, compatibility: nextCompatibility,
invocationMode: nextInvocationMode, 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); setScope(nextScope);
setRootKind(nextRootKind); setRootKind(nextRootKind);
setFolderName(nextFolderName || nextName || ''); setFolderName(nextFolderName || suggestedFolderName || nextName || '');
setFolderNameEdited(Boolean(item?.folderName));
setName(rawInput.name || nextName || 'New Skill'); setName(rawInput.name || nextName || 'New Skill');
setDescription( setDescription(
rawInput.description || nextDescription || 'Describe what this skill helps with.' rawInput.description || nextDescription || 'Describe what this skill helps with.'
@ -156,14 +205,26 @@ export const SkillEditorDialog = ({
setLicense(rawInput.license ?? nextLicense); setLicense(rawInput.license ?? nextLicense);
setCompatibility(rawInput.compatibility ?? nextCompatibility); setCompatibility(rawInput.compatibility ?? nextCompatibility);
setInvocationMode(rawInput.invocationMode ?? nextInvocationMode); 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); setIncludeScripts(item?.flags.hasScripts ?? false);
setIncludeReferences(item?.flags.hasReferences ?? false); setIncludeReferences(item?.flags.hasReferences ?? false);
setIncludeAssets(item?.flags.hasAssets ?? false); setIncludeAssets(item?.flags.hasAssets ?? false);
setCustomMarkdownDetected(hasCustomMarkdown);
rawContentRef.current = nextRawContent; rawContentRef.current = nextRawContent;
setRawContent(nextRawContent); setRawContent(nextRawContent);
setManualRawEdit(false); setManualRawEdit(false);
setShowAdvancedEditor(hasCustomMarkdown);
setReviewPreview(null); setReviewPreview(null);
setReviewOpen(false); setReviewOpen(false);
setReviewLoading(false);
setSaveLoading(false);
setMutationError(null);
}, [detail, mode, open, projectPath]); }, [detail, mode, open, projectPath]);
useEffect(() => { useEffect(() => {
@ -207,11 +268,28 @@ export const SkillEditorDialog = ({
); );
const canUseProjectScope = Boolean(projectPath); const canUseProjectScope = Boolean(projectPath);
const instructionsLocked = manualRawEdit || customMarkdownDetected;
const title = mode === 'create' ? 'Create skill' : 'Edit skill'; const title = mode === 'create' ? 'Create skill' : 'Edit skill';
const descriptionText = const descriptionText =
mode === 'create' mode === 'create'
? 'Draft a new local skill, review the filesystem changes, then save it into a supported skill root.' ? 'Describe the workflow in plain language, review the files that will be created, then save it.'
: 'Update the selected skill and review the resulting file changes before saving.'; : '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 handleMouseMove = useCallback((event: MouseEvent): void => {
const container = containerRef.current; const container = containerRef.current;
@ -242,16 +320,40 @@ export const SkillEditorDialog = ({
}, [handleMouseMove, handleMouseUp, isResizing]); }, [handleMouseMove, handleMouseUp, isResizing]);
async function handleReview(): Promise<void> { async function handleReview(): Promise<void> {
const preview = await previewSkillUpsert(request); const validationError = validateBeforeReview();
setReviewPreview(preview); if (validationError) {
setReviewOpen(true); 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> { async function handleConfirmSave(): Promise<void> {
const saved = await applySkillUpsert(request); setSaveLoading(true);
setReviewOpen(false); setMutationError(null);
onSaved(saved?.item.id ?? detail?.item.id ?? null); try {
onClose(); 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 ( return (
@ -266,9 +368,17 @@ export const SkillEditorDialog = ({
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5"> <div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="space-y-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="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="skill-scope">Scope</Label> <Label htmlFor="skill-scope">Who can use it</Label>
<Select <Select
value={scope} value={scope}
onValueChange={(value) => setScope(value as 'user' | 'project')} onValueChange={(value) => setScope(value as 'user' | 'project')}
@ -289,7 +399,7 @@ export const SkillEditorDialog = ({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="skill-root">Root</Label> <Label htmlFor="skill-root">Where to store it</Label>
<Select <Select
value={rootKind} value={rootKind}
onValueChange={(value) => onValueChange={(value) =>
@ -313,27 +423,36 @@ export const SkillEditorDialog = ({
<Input <Input
id="skill-folder" id="skill-folder"
value={folderName} value={folderName}
onChange={(event) => setFolderName(event.target.value)} onChange={(event) => {
setFolderNameEdited(true);
setFolderName(event.target.value);
}}
disabled={mode === 'edit'} 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>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="skill-invocation">Invocation</Label> <Label htmlFor="skill-invocation">How Claude should use it</Label>
<Select <Select
value={invocationMode} value={invocationMode}
onValueChange={(value) => { onValueChange={(value) => {
const nextValue = value as SkillInvocationMode; const nextValue = value as SkillInvocationMode;
setInvocationMode(nextValue); setInvocationMode(nextValue);
applyMetadataToRawContent({ invocationMode: nextValue }); applyFormToRawContent({ invocationMode: nextValue });
}} }}
> >
<SelectTrigger id="skill-invocation"> <SelectTrigger id="skill-invocation">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto</SelectItem> <SelectItem value="auto">Claude can use it automatically</SelectItem>
<SelectItem value="manual-only">Manual only</SelectItem> <SelectItem value="manual-only">Only when you ask for it</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -348,7 +467,10 @@ export const SkillEditorDialog = ({
onChange={(event) => { onChange={(event) => {
const nextValue = event.target.value; const nextValue = event.target.value;
setName(nextValue); setName(nextValue);
applyMetadataToRawContent({ name: nextValue }); if (mode === 'create' && !folderNameEdited) {
setFolderName(toSuggestedFolderName(nextValue || 'New Skill'));
}
applyFormToRawContent({ name: nextValue });
}} }}
placeholder="Write concise skill name" placeholder="Write concise skill name"
/> />
@ -361,7 +483,7 @@ export const SkillEditorDialog = ({
onChange={(event) => { onChange={(event) => {
const nextValue = event.target.value; const nextValue = event.target.value;
setLicense(nextValue); setLicense(nextValue);
applyMetadataToRawContent({ license: nextValue }); applyFormToRawContent({ license: nextValue });
}} }}
placeholder="MIT" placeholder="MIT"
/> />
@ -377,7 +499,7 @@ export const SkillEditorDialog = ({
onChange={(event) => { onChange={(event) => {
const nextValue = event.target.value; const nextValue = event.target.value;
setDescription(nextValue); setDescription(nextValue);
applyMetadataToRawContent({ description: nextValue }); applyFormToRawContent({ description: nextValue });
}} }}
placeholder="What this skill helps with" placeholder="What this skill helps with"
/> />
@ -390,13 +512,90 @@ export const SkillEditorDialog = ({
onChange={(event) => { onChange={(event) => {
const nextValue = event.target.value; const nextValue = event.target.value;
setCompatibility(nextValue); setCompatibility(nextValue);
applyMetadataToRawContent({ compatibility: nextValue }); applyFormToRawContent({ compatibility: nextValue });
}} }}
placeholder="claude-code, cursor" placeholder="claude-code, cursor"
/> />
</div> </div>
</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="rounded-lg border border-border p-4">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
@ -423,7 +622,7 @@ export const SkillEditorDialog = ({
<div> <div>
<p className="font-medium text-text">References</p> <p className="font-medium text-text">References</p>
<p className="mt-1 text-xs text-text-muted"> <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> </p>
</div> </div>
</label> </label>
@ -437,7 +636,8 @@ export const SkillEditorDialog = ({
<div> <div>
<p className="font-medium text-text">Scripts</p> <p className="font-medium text-text">Scripts</p>
<p className="mt-1 text-xs text-text-muted"> <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> </p>
</div> </div>
</label> </label>
@ -451,7 +651,7 @@ export const SkillEditorDialog = ({
<div> <div>
<p className="font-medium text-text">Assets</p> <p className="font-medium text-text">Assets</p>
<p className="mt-1 text-xs text-text-muted"> <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> </p>
</div> </div>
</label> </label>
@ -473,74 +673,117 @@ export const SkillEditorDialog = ({
)} )}
</div> </div>
{skillsMutationError && ( {mutationError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400"> <div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400">
{skillsMutationError} {mutationError}
</div> </div>
)} )}
<div className="space-y-2"> <section className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<Label htmlFor="skill-raw">SKILL.md</Label> <div>
<Button <h3 className="text-sm font-semibold text-text">
variant="outline" {customMarkdownDetected
size="sm" ? '2. SKILL.md editor'
onClick={() => { : '4. Advanced SKILL.md editor'}
setManualRawEdit(false); </h3>
const nextRawContent = buildSkillTemplate({ <p className="text-sm text-text-muted">
name, {customMarkdownDetected
description, ? 'This skill uses a custom markdown format, so edit it directly here.'
license, : 'Most people can skip this. Open it only if you want direct control over the raw markdown file.'}
compatibility, </p>
invocationMode, </div>
}); {!customMarkdownDetected && (
rawContentRef.current = nextRawContent; <Button
setRawContent(nextRawContent); variant="outline"
}} size="sm"
> onClick={() => setShowAdvancedEditor((prev) => !prev)}
<RotateCcw className="mr-1.5 size-3.5" /> >
Reset From Template {showAdvancedEditor ? 'Hide Advanced Editor' : 'Show Advanced Editor'}
</Button> </Button>
)}
</div> </div>
<div {showAdvancedEditor && (
ref={containerRef} <div className="space-y-2">
className="flex h-[520px] min-h-0 overflow-hidden rounded-lg border border-border" <div className="flex items-center justify-between">
> <Label htmlFor="skill-raw">SKILL.md</Label>
<div className="min-w-0" style={{ width: `${splitRatio * 100}%` }}> <Button
<SkillCodeEditor variant="outline"
value={rawContent} size="sm"
onChange={(value) => { onClick={() => {
setManualRawEdit(true); setManualRawEdit(false);
rawContentRef.current = value; const nextRawContent = buildSkillTemplate({
setRawContent(value); 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); <div
if (rawInput.name !== undefined) setName(rawInput.name); ref={containerRef}
if (rawInput.description !== undefined) className="flex h-[520px] min-h-0 overflow-hidden rounded-lg border border-border"
setDescription(rawInput.description); >
if (rawInput.license !== undefined) setLicense(rawInput.license); <div className="min-w-0" style={{ width: `${splitRatio * 100}%` }}>
if (rawInput.compatibility !== undefined) <SkillCodeEditor
setCompatibility(rawInput.compatibility); value={rawContent}
if (rawInput.invocationMode !== undefined) scrollRef={editorScrollRef}
setInvocationMode(rawInput.invocationMode); 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>
<div )}
className={`w-1 shrink-0 cursor-col-resize border-x border-border ${ </section>
isResizing ? 'bg-blue-500/50' : 'hover:bg-blue-500/30'
}`}
onMouseDown={(event) => {
event.preventDefault();
setIsResizing(true);
}}
/>
<div className="min-w-0 flex-1 overflow-hidden">
<MarkdownPreviewPane content={rawContent} baseDir={detail?.item.skillDir} />
</div>
</div>
</div>
</div> </div>
</div> </div>
@ -549,12 +792,15 @@ export const SkillEditorDialog = ({
<X className="mr-1.5 size-3.5" /> <X className="mr-1.5 size-3.5" />
Cancel Cancel
</Button> </Button>
<p className="min-w-[16rem] flex-1 text-sm text-text-muted"> <div className="min-w-[16rem] flex-1">
Review the file changes first, then confirm save in the next step. <p className="text-sm text-text-muted">
</p> Review the file changes first, then confirm save in the next step.
<Button onClick={() => void handleReview()} disabled={skillsMutationLoading}> </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" /> <FileSearch className="mr-1.5 size-3.5" />
{skillsMutationLoading {reviewLoading
? 'Preparing...' ? 'Preparing...'
: mode === 'create' : mode === 'create'
? 'Review And Create' ? 'Review And Create'
@ -568,7 +814,8 @@ export const SkillEditorDialog = ({
<SkillReviewDialog <SkillReviewDialog
open={reviewOpen} open={reviewOpen}
preview={reviewPreview} preview={reviewPreview}
loading={skillsMutationLoading} loading={saveLoading}
error={mutationError}
onClose={() => setReviewOpen(false)} onClose={() => setReviewOpen(false)}
onConfirm={() => void handleConfirmSave()} onConfirm={() => void handleConfirmSave()}
confirmLabel={mode === 'create' ? 'Create Skill' : 'Save Skill'} confirmLabel={mode === 'create' ? 'Create Skill' : 'Save Skill'}

View file

@ -26,6 +26,28 @@ import { SkillReviewDialog } from './SkillReviewDialog';
import type { SkillReviewPreview } from '@shared/types/extensions'; 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 { interface SkillImportDialogProps {
open: boolean; open: boolean;
projectPath: string | null; projectPath: string | null;
@ -43,8 +65,6 @@ export const SkillImportDialog = ({
}: SkillImportDialogProps): React.JSX.Element => { }: SkillImportDialogProps): React.JSX.Element => {
const previewSkillImport = useStore((s) => s.previewSkillImport); const previewSkillImport = useStore((s) => s.previewSkillImport);
const applySkillImport = useStore((s) => s.applySkillImport); const applySkillImport = useStore((s) => s.applySkillImport);
const skillsMutationLoading = useStore((s) => s.skillsMutationLoading);
const skillsMutationError = useStore((s) => s.skillsMutationError);
const [sourceDir, setSourceDir] = useState(''); const [sourceDir, setSourceDir] = useState('');
const [folderName, setFolderName] = useState(''); const [folderName, setFolderName] = useState('');
@ -52,6 +72,9 @@ export const SkillImportDialog = ({
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude'); const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
const [preview, setPreview] = useState<SkillReviewPreview | null>(null); const [preview, setPreview] = useState<SkillReviewPreview | null>(null);
const [reviewOpen, setReviewOpen] = useState(false); const [reviewOpen, setReviewOpen] = useState(false);
const [reviewLoading, setReviewLoading] = useState(false);
const [importLoading, setImportLoading] = useState(false);
const [mutationError, setMutationError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -61,6 +84,9 @@ export const SkillImportDialog = ({
setRootKind('claude'); setRootKind('claude');
setPreview(null); setPreview(null);
setReviewOpen(false); setReviewOpen(false);
setReviewLoading(false);
setImportLoading(false);
setMutationError(null);
}, [open, projectPath]); }, [open, projectPath]);
async function handleChooseFolder(): Promise<void> { async function handleChooseFolder(): Promise<void> {
@ -75,28 +101,51 @@ export const SkillImportDialog = ({
} }
async function handleReview(): Promise<void> { async function handleReview(): Promise<void> {
const nextPreview = await previewSkillImport({ setReviewLoading(true);
sourceDir, setMutationError(null);
folderName: folderName || undefined, try {
scope, const nextPreview = await previewSkillImport({
rootKind, sourceDir,
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, folderName: folderName || undefined,
}); scope,
setPreview(nextPreview); rootKind,
setReviewOpen(true); 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> { async function handleConfirmImport(): Promise<void> {
const detail = await applySkillImport({ setImportLoading(true);
sourceDir, setMutationError(null);
folderName: folderName || undefined, try {
scope, const detail = await applySkillImport({
rootKind, sourceDir,
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, folderName: folderName || undefined,
}); scope,
setReviewOpen(false); rootKind,
onImported(detail?.item.id ?? null); projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
onClose(); 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 ( return (
@ -107,13 +156,20 @@ export const SkillImportDialog = ({
<DialogHeader className="border-b border-border px-6 py-5"> <DialogHeader className="border-b border-border px-6 py-5">
<DialogTitle>Import skill</DialogTitle> <DialogTitle>Import skill</DialogTitle>
<DialogDescription> <DialogDescription>
Pick an existing skill folder, review the copy plan, then import it into a supported Pick an existing skill folder, review what will be copied, then import it into one
root. of your supported skill locations.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5"> <div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="space-y-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"> <div className="space-y-2">
<Label htmlFor="skill-import-source">Source folder</Label> <Label htmlFor="skill-import-source">Source folder</Label>
<div className="flex gap-2"> <div className="flex gap-2">
@ -139,9 +195,15 @@ export const SkillImportDialog = ({
/> />
</div> </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="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="skill-import-scope">Scope</Label> <Label htmlFor="skill-import-scope">Who can use it</Label>
<Select <Select
value={scope} value={scope}
onValueChange={(value) => setScope(value as 'user' | 'project')} onValueChange={(value) => setScope(value as 'user' | 'project')}
@ -161,7 +223,7 @@ export const SkillImportDialog = ({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="skill-import-root">Root</Label> <Label htmlFor="skill-import-root">Where to store it</Label>
<Select <Select
value={rootKind} value={rootKind}
onValueChange={(value) => onValueChange={(value) =>
@ -180,9 +242,9 @@ export const SkillImportDialog = ({
</div> </div>
</div> </div>
{skillsMutationError && ( {mutationError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400"> <div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400">
{skillsMutationError} {mutationError}
</div> </div>
)} )}
</div> </div>
@ -198,10 +260,10 @@ export const SkillImportDialog = ({
</p> </p>
<Button <Button
onClick={() => void handleReview()} onClick={() => void handleReview()}
disabled={!sourceDir || skillsMutationLoading} disabled={!sourceDir || reviewLoading || importLoading}
> >
<FileSearch className="mr-1.5 size-3.5" /> <FileSearch className="mr-1.5 size-3.5" />
{skillsMutationLoading ? 'Preparing...' : 'Review And Import'} {reviewLoading ? 'Preparing...' : 'Review And Import'}
</Button> </Button>
</div> </div>
</div> </div>
@ -211,11 +273,13 @@ export const SkillImportDialog = ({
<SkillReviewDialog <SkillReviewDialog
open={reviewOpen} open={reviewOpen}
preview={preview} preview={preview}
loading={skillsMutationLoading} loading={importLoading}
error={mutationError}
onClose={() => setReviewOpen(false)} onClose={() => setReviewOpen(false)}
onConfirm={() => void handleConfirmImport()} onConfirm={() => void handleConfirmImport()}
confirmLabel="Import Skill" confirmLabel="Import Skill"
reviewLabel="Importing this skill" reviewLabel="Importing this skill"
backLabel="Back To Import"
/> />
</> </>
); );

View file

@ -17,20 +17,24 @@ interface SkillReviewDialogProps {
open: boolean; open: boolean;
preview: SkillReviewPreview | null; preview: SkillReviewPreview | null;
loading?: boolean; loading?: boolean;
error?: string | null;
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
confirmLabel: string; confirmLabel: string;
reviewLabel: string; reviewLabel: string;
backLabel?: string;
} }
export const SkillReviewDialog = ({ export const SkillReviewDialog = ({
open, open,
preview, preview,
loading = false, loading = false,
error = null,
onClose, onClose,
onConfirm, onConfirm,
confirmLabel, confirmLabel,
reviewLabel, reviewLabel,
backLabel = 'Back To Editor',
}: SkillReviewDialogProps): React.JSX.Element => { }: SkillReviewDialogProps): React.JSX.Element => {
const hasChanges = Boolean(preview && preview.changes.length > 0); 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="bg-surface-raised/10 rounded-lg border border-border p-4">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{preview.changes.length} file changes</Badge> <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>
<div className="mt-3 break-all rounded-md border border-border bg-surface px-3 py-2 font-mono text-xs text-text-muted"> <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} {preview.targetSkillDir}
@ -73,6 +89,12 @@ export const SkillReviewDialog = ({
</div> </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 && ( {!hasChanges && (
<div className="bg-surface-raised/10 rounded-md border border-border p-4 text-sm text-text-muted"> <div className="bg-surface-raised/10 rounded-md border border-border p-4 text-sm text-text-muted">
No file changes detected yet. 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)]"> <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}> <Button variant="outline" onClick={onClose}>
<ChevronLeft className="mr-1.5 size-3.5" /> <ChevronLeft className="mr-1.5 size-3.5" />
Back To Editor {backLabel}
</Button> </Button>
<Button onClick={onConfirm} disabled={loading || !preview || !hasChanges}> <Button onClick={onConfirm} disabled={loading || !preview || !hasChanges}>
{loading ? ( {loading ? (

View file

@ -25,10 +25,13 @@ import { SkillDetailDialog } from './SkillDetailDialog';
import { SkillEditorDialog } from './SkillEditorDialog'; import { SkillEditorDialog } from './SkillEditorDialog';
import { SkillImportDialog } from './SkillImportDialog'; 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'; import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState';
const SUCCESS_BANNER_MS = 2500; 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 { interface SkillsPanelProps {
projectPath: string | null; projectPath: string | null;
@ -56,6 +59,26 @@ function formatRootKind(rootKind: SkillCatalogItem['rootKind']): string {
return `.${rootKind}`; 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 = ({ export const SkillsPanel = ({
projectPath, projectPath,
projectLabel, projectLabel,
@ -66,10 +89,11 @@ export const SkillsPanel = ({
selectedSkillId, selectedSkillId,
setSelectedSkillId, setSelectedSkillId,
}: SkillsPanelProps): React.JSX.Element => { }: SkillsPanelProps): React.JSX.Element => {
const catalogKey = projectPath ?? USER_SKILLS_CATALOG_KEY;
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail); const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
const skillsLoading = useStore((s) => s.skillsLoading); const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
const skillsError = useStore((s) => s.skillsError); const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
const detailById = useStore((s) => s.skillsDetailsById); const detailById = useStore((s) => s.skillsDetailsById);
const userSkills = useStore((s) => s.skillsUserCatalog); const userSkills = useStore((s) => s.skillsUserCatalog);
const projectSkills = useStore((s) => const projectSkills = useStore((s) =>
@ -77,9 +101,12 @@ export const SkillsPanel = ({
); );
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [editingDetail, setEditingDetail] = useState<SkillDetail | null>(null);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [sortMenuOpen, setSortMenuOpen] = useState(false); const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [quickFilter, setQuickFilter] = useState<SkillsQuickFilter>('all');
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [highlightedSkillId, setHighlightedSkillId] = useState<string | null>(null);
const selectedSkillIdRef = useRef<string | null>(selectedSkillId); const selectedSkillIdRef = useRef<string | null>(selectedSkillId);
selectedSkillIdRef.current = selectedSkillId; selectedSkillIdRef.current = selectedSkillId;
@ -101,6 +128,12 @@ export const SkillsPanel = ({
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [successMessage]); }, [successMessage]);
useEffect(() => {
if (!highlightedSkillId) return;
const timeoutId = window.setTimeout(() => setHighlightedSkillId(null), NEW_SKILL_HIGHLIGHT_MS);
return () => window.clearTimeout(timeoutId);
}, [highlightedSkillId]);
useEffect(() => { useEffect(() => {
const skillsApi = api.skills; const skillsApi = api.skills;
if (!skillsApi) return; if (!skillsApi) return;
@ -122,7 +155,9 @@ export const SkillsPanel = ({
void fetchSkillsCatalog(projectPath ?? undefined); void fetchSkillsCatalog(projectPath ?? undefined);
if (selectedSkillIdRef.current) { 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 visibleSkills = useMemo(() => {
const q = skillsSearchQuery.trim().toLowerCase(); const q = skillsSearchQuery.trim().toLowerCase();
const filtered = q const filteredByQuery = q
? mergedSkills.filter( ? mergedSkills.filter(
(skill) => (skill) =>
skill.name.toLowerCase().includes(q) || skill.name.toLowerCase().includes(q) ||
@ -145,8 +180,34 @@ export const SkillsPanel = ({
skill.folderName.toLowerCase().includes(q) skill.folderName.toLowerCase().includes(q)
) )
: mergedSkills; : 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); 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 ( return (
<div className="flex flex-col gap-4"> <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="min-w-0 flex-1 space-y-1 xl:max-w-2xl">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BookOpen className="size-4 text-text-muted" /> <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> </div>
<p className="max-w-2xl text-sm leading-5 text-text-muted"> <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 {projectPath
? `Project skills for ${projectLabel ?? projectPath} plus your user-level skills.` ? `You are seeing skills for ${projectLabel ?? projectPath} plus your personal skills.`
: 'User-level skills only. Select a project to include project-scoped skill roots.'} : '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> </p>
</div> </div>
@ -170,7 +237,7 @@ export const SkillsPanel = ({
<SearchInput <SearchInput
value={skillsSearchQuery} value={skillsSearchQuery}
onChange={setSkillsSearchQuery} onChange={setSkillsSearchQuery}
placeholder="Search skills..." placeholder="Search by skill name or what it helps with..."
/> />
</div> </div>
<div className="flex flex-wrap gap-2"> <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"> <div className="flex flex-wrap gap-2 text-[11px] text-text-muted xl:justify-end">
<Badge variant="secondary" className="font-normal"> <Badge variant="secondary" className="font-normal">
{mergedSkills.length} discovered {mergedSkills.length} total
</Badge> </Badge>
<Badge variant="secondary" className="font-normal"> <Badge variant="secondary" className="font-normal">
{projectSkills.length} project {projectSkills.length} project
</Badge> </Badge>
<Badge variant="secondary" className="font-normal"> <Badge variant="secondary" className="font-normal">
{userSkills.length} user {userSkills.length} personal
</Badge> </Badge>
</div> </div>
</div> </div>
</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 && ( {skillsError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400"> <div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
{skillsError} {skillsError}
@ -256,6 +345,12 @@ export const SkillsPanel = ({
</div> </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 && ( {skillsLoading && visibleSkills.length === 0 && (
<div className="rounded-lg border border-border p-6 text-sm text-text-muted"> <div className="rounded-lg border border-border p-6 text-sm text-text-muted">
Loading skills... Loading skills...
@ -268,77 +363,183 @@ export const SkillsPanel = ({
<Search className="size-5 text-text-muted" /> <Search className="size-5 text-text-muted" />
</div> </div>
<p className="text-sm text-text-secondary"> <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>
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
{skillsSearchQuery {skillsSearchQuery
? 'Try a different search term.' ? 'Try a different search term or switch filters.'
: 'Skills are discovered from .claude/skills, .cursor/skills, and .agents/skills roots.'} : 'Create your first skill to teach Claude a repeatable workflow, or import one you already use.'}
</p> </p>
</div> </div>
)} )}
{visibleSkills.length > 0 && ( {visibleSkills.length > 0 && (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2"> <div className="space-y-6">
{visibleSkills.map((skill) => ( {visibleProjectSkills.length > 0 && (
<button <section className="space-y-3">
key={skill.id} <div className="flex items-center justify-between gap-3">
type="button" <div>
onClick={() => setSelectedSkillId(skill.id)} <h3 className="text-sm font-semibold text-text">Project skills</h3>
className="bg-surface-raised/10 rounded-xl border border-border p-4 text-left transition-colors hover:border-border-emphasis" <p className="text-xs text-text-muted">
> Workflows that only make sense for this codebase.
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold text-text">{skill.name}</h3>
{!skill.isValid && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
>
Needs attention
</Badge>
)}
</div>
<p className="mt-1 line-clamp-2 text-sm text-text-secondary">
{skill.description}
</p> </p>
</div> </div>
<Badge variant="outline">{skill.scope}</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="font-normal"> <Badge variant="secondary" className="font-normal">
{formatRootKind(skill.rootKind)} {visibleProjectSkills.length}
</Badge> </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>
<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 space-y-2 text-xs text-text-muted">
<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"> <p>{getInvocationLabel(skill)}</p>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" /> <p>{getSkillStatus(skill)}</p>
<span>{skill.issues[0]?.message}</span> </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> </div>
)} <Badge variant="secondary" className="font-normal">
</button> {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> </div>
)} )}
@ -347,7 +548,12 @@ export const SkillsPanel = ({
open={selectedSkillId !== null} open={selectedSkillId !== null}
onClose={() => setSelectedSkillId(null)} onClose={() => setSelectedSkillId(null)}
projectPath={projectPath} projectPath={projectPath}
onEdit={() => setEditOpen(true)} onEdit={() => {
if (!selectedDetail) return;
setEditingDetail(selectedDetail);
setSelectedSkillId(null);
setEditOpen(true);
}}
onDeleted={() => setSelectedSkillId(null)} onDeleted={() => setSelectedSkillId(null)}
/> />
@ -361,7 +567,8 @@ export const SkillsPanel = ({
onSaved={(skillId) => { onSaved={(skillId) => {
setCreateOpen(false); setCreateOpen(false);
setSuccessMessage('Skill created successfully.'); setSuccessMessage('Skill created successfully.');
setSelectedSkillId(skillId); setHighlightedSkillId(skillId);
setSelectedSkillId(null);
}} }}
/> />
@ -370,10 +577,14 @@ export const SkillsPanel = ({
mode="edit" mode="edit"
projectPath={projectPath} projectPath={projectPath}
projectLabel={projectLabel} projectLabel={projectLabel}
detail={selectedDetail} detail={editingDetail}
onClose={() => setEditOpen(false)} onClose={() => {
setEditOpen(false);
setEditingDetail(null);
}}
onSaved={(skillId) => { onSaved={(skillId) => {
setEditOpen(false); setEditOpen(false);
setEditingDetail(null);
setSuccessMessage('Skill saved successfully.'); setSuccessMessage('Skill saved successfully.');
setSelectedSkillId(skillId); setSelectedSkillId(skillId);
}} }}

View file

@ -3,6 +3,7 @@ import YAML from 'yaml';
import type { SkillDraftFile, SkillDraftTemplateInput } from '@shared/types/extensions'; import type { SkillDraftFile, SkillDraftTemplateInput } from '@shared/types/extensions';
const SKILL_FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/u; 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 { export interface SkillDraftOptions {
rawContent: string; rawContent: string;
@ -11,6 +12,12 @@ export interface SkillDraftOptions {
includeAssets: boolean; includeAssets: boolean;
} }
export interface SkillTemplateParseResult extends Partial<SkillDraftTemplateInput> {
bodyMarkdown?: string;
hasStructuredSections: boolean;
hasUnstructuredBody: boolean;
}
function trimTrailingWhitespace(value: string): string { function trimTrailingWhitespace(value: string): string {
return value return value
.split('\n') .split('\n')
@ -20,6 +27,16 @@ function trimTrailingWhitespace(value: string): string {
} }
export function buildSkillTemplate(input: SkillDraftTemplateInput): 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 = [ const lines = [
'---', '---',
`name: ${input.name || 'New Skill'}`, `name: ${input.name || 'New Skill'}`,
@ -34,45 +51,80 @@ export function buildSkillTemplate(input: SkillDraftTemplateInput): string {
input.description || 'Describe what this skill helps with.', input.description || 'Describe what this skill helps with.',
'', '',
'## When to use', '## When to use',
'- Add the conditions where this skill should be selected.', ...whenToUse,
'', '',
'## Steps', '## Steps',
'1. Describe the first step.', ...steps,
'2. Describe the second step.',
'', '',
'## Notes', '## Notes',
'- Add caveats, review rules, or references.', ...notes,
]; ];
return trimTrailingWhitespace(lines.join('\n')); 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 content = rawContent.replace(/^\uFEFF/u, '');
const match = content.match(SKILL_FRONTMATTER_PATTERN); const match = content.match(SKILL_FRONTMATTER_PATTERN);
if (!match) { if (!match) {
return {}; return {
hasStructuredSections: false,
hasUnstructuredBody: content.trim().length > 0,
bodyMarkdown: content.trim() || undefined,
};
} }
try { try {
const parsed = YAML.parse(match[1]); const parsed = YAML.parse(match[1]);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { 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 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 { return {
name: typeof data.name === 'string' ? data.name : undefined, name: typeof data.name === 'string' ? data.name : undefined,
description: typeof data.description === 'string' ? data.description : undefined, description: typeof data.description === 'string' ? data.description : undefined,
license: typeof data.license === 'string' ? data.license : undefined, license: typeof data.license === 'string' ? data.license : undefined,
compatibility: typeof data.compatibility === 'string' ? data.compatibility : undefined, compatibility: typeof data.compatibility === 'string' ? data.compatibility : undefined,
invocationMode: data['disable-model-invocation'] === true ? 'manual-only' : 'auto', invocationMode: data['disable-model-invocation'] === true ? 'manual-only' : 'auto',
whenToUse,
steps,
notes,
bodyMarkdown,
hasStructuredSections,
hasUnstructuredBody: Boolean(bodyMarkdown) && !hasStructuredSections,
}; };
} catch { } 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( export function updateSkillTemplateFrontmatter(
rawContent: string, rawContent: string,
input: SkillDraftTemplateInput input: SkillDraftTemplateInput
@ -145,3 +197,38 @@ export function buildSkillDraftFiles(options: SkillDraftOptions): SkillDraftFile
return files; 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 { export interface UseMarkdownScrollSyncResult {
previewScrollRef: React.RefObject<HTMLDivElement | null>; previewScrollRef: React.RefObject<HTMLDivElement | null>;
/** Attach to editor scroll container when using a local CodeMirror instance */
handleCodeScroll: () => void;
/** Attach to preview div's onScroll */ /** Attach to preview div's onScroll */
handlePreviewScroll: () => void; handlePreviewScroll: () => void;
} }
interface UseMarkdownScrollSyncOptions {
editorScrollRef?: React.RefObject<HTMLElement | null>;
}
// ============================================================================= // =============================================================================
// Hook // Hook
// ============================================================================= // =============================================================================
@ -46,13 +52,17 @@ export interface UseMarkdownScrollSyncResult {
*/ */
export function useMarkdownScrollSync( export function useMarkdownScrollSync(
enabled: boolean, enabled: boolean,
viewKey?: string | null viewKey?: string | null,
options?: UseMarkdownScrollSyncOptions
): UseMarkdownScrollSyncResult { ): UseMarkdownScrollSyncResult {
const previewScrollRef = useRef<HTMLDivElement | null>(null); const previewScrollRef = useRef<HTMLDivElement | null>(null);
const ignoreCodeScroll = useRef(false); const ignoreCodeScroll = useRef(false);
const ignorePreviewScroll = useRef(false); const ignorePreviewScroll = useRef(false);
const codeRafRef = useRef(0); const codeRafRef = useRef(0);
const previewRafRef = useRef(0); const previewRafRef = useRef(0);
const getEditorScrollElement = useCallback(() => {
return options?.editorScrollRef?.current ?? editorBridge.getView()?.scrollDOM ?? null;
}, [options?.editorScrollRef]);
// Code → Preview: proportional scroll // Code → Preview: proportional scroll
const handleCodeScroll = useCallback(() => { const handleCodeScroll = useCallback(() => {
@ -62,7 +72,7 @@ export function useMarkdownScrollSync(
return; return;
} }
const scrollDOM = editorBridge.getView()?.scrollDOM; const scrollDOM = getEditorScrollElement();
const preview = previewScrollRef.current; const preview = previewScrollRef.current;
if (!scrollDOM || !preview) return; if (!scrollDOM || !preview) return;
@ -78,7 +88,7 @@ export function useMarkdownScrollSync(
ignorePreviewScroll.current = true; ignorePreviewScroll.current = true;
preview.scrollTop = fraction * maxPreview; preview.scrollTop = fraction * maxPreview;
}); });
}, [enabled]); }, [enabled, getEditorScrollElement]);
// Preview → Code: proportional scroll // Preview → Code: proportional scroll
const handlePreviewScroll = useCallback(() => { const handlePreviewScroll = useCallback(() => {
@ -88,7 +98,7 @@ export function useMarkdownScrollSync(
return; return;
} }
const scrollDOM = editorBridge.getView()?.scrollDOM; const scrollDOM = getEditorScrollElement();
const preview = previewScrollRef.current; const preview = previewScrollRef.current;
if (!scrollDOM || !preview) return; if (!scrollDOM || !preview) return;
@ -104,7 +114,7 @@ export function useMarkdownScrollSync(
ignoreCodeScroll.current = true; ignoreCodeScroll.current = true;
scrollDOM.scrollTop = fraction * maxCode; scrollDOM.scrollTop = fraction * maxCode;
}); });
}, [enabled]); }, [enabled, getEditorScrollElement]);
// Auto-attach code scroll listener with retry on mount/viewKey change // Auto-attach code scroll listener with retry on mount/viewKey change
useEffect(() => { useEffect(() => {
@ -115,7 +125,7 @@ export function useMarkdownScrollSync(
let attempts = 0; let attempts = 0;
const tryAttach = (): void => { const tryAttach = (): void => {
const scrollDOM = editorBridge.getView()?.scrollDOM; const scrollDOM = getEditorScrollElement();
if (!scrollDOM) { if (!scrollDOM) {
if (attempts < MAX_ATTACH_ATTEMPTS) { if (attempts < MAX_ATTACH_ATTEMPTS) {
attempts++; attempts++;
@ -138,10 +148,11 @@ export function useMarkdownScrollSync(
cancelAnimationFrame(codeRafRef.current); cancelAnimationFrame(codeRafRef.current);
cancelAnimationFrame(previewRafRef.current); cancelAnimationFrame(previewRafRef.current);
}; };
}, [enabled, viewKey, handleCodeScroll]); }, [enabled, viewKey, handleCodeScroll, getEditorScrollElement]);
return { return {
previewScrollRef, previewScrollRef,
handleCodeScroll,
handlePreviewScroll, handlePreviewScroll,
}; };
} }

View file

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

View file

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

View file

@ -85,9 +85,12 @@ export interface SkillDraftTemplateInput {
invocationMode: SkillInvocationMode; invocationMode: SkillInvocationMode;
license: string; license: string;
compatibility: string; compatibility: string;
whenToUse: string;
steps: string;
notes: string;
} }
export type SkillReviewAction = 'create' | 'update'; export type SkillReviewAction = 'create' | 'update' | 'delete';
export interface SkillReviewFileChange { export interface SkillReviewFileChange {
relativePath: string; relativePath: string;
@ -98,10 +101,19 @@ export interface SkillReviewFileChange {
isBinary: boolean; isBinary: boolean;
} }
export interface SkillReviewSummary {
created: number;
updated: number;
deleted: number;
binary: number;
}
export interface SkillReviewPreview { export interface SkillReviewPreview {
planId: string;
targetSkillDir: string; targetSkillDir: string;
changes: SkillReviewFileChange[]; changes: SkillReviewFileChange[];
warnings: string[]; warnings: string[];
summary: SkillReviewSummary;
} }
export interface SkillUpsertRequest { export interface SkillUpsertRequest {
@ -111,6 +123,7 @@ export interface SkillUpsertRequest {
folderName: string; folderName: string;
existingSkillId?: string; existingSkillId?: string;
files: SkillDraftFile[]; files: SkillDraftFile[];
reviewPlanId?: string;
} }
export interface SkillImportRequest { export interface SkillImportRequest {
@ -119,6 +132,7 @@ export interface SkillImportRequest {
rootKind: SkillRootKind; rootKind: SkillRootKind;
projectPath?: string; projectPath?: string;
folderName?: string; folderName?: string;
reviewPlanId?: string;
} }
export interface SkillDeleteRequest { 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 { 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 => ({ const makePlugin = (overrides: Partial<EnrichedPlugin>): EnrichedPlugin => ({
pluginId: 'test@marketplace', pluginId: 'test@marketplace',
@ -75,6 +80,42 @@ const makeMcpServer = (overrides: Partial<McpCatalogItem>): McpCatalogItem => ({
...overrides, ...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', () => { describe('extensionsSlice', () => {
let store: TestStore; let store: TestStore;
@ -296,4 +337,64 @@ describe('extensionsSlice', () => {
expect(store.getState().mcpInstallProgress['test-id']).toBe('success'); 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');
});
});
}); });