fix(extensions): sanitize suggested skill folder names

This commit is contained in:
777genius 2026-04-16 23:08:00 +03:00
parent 4578f11dd7
commit 86bc4ce4e8
5 changed files with 88 additions and 18 deletions

View file

@ -32,6 +32,7 @@ import {
readSkillTemplateContent,
updateSkillTemplateFrontmatter,
} from './skillDraftUtils';
import { toSuggestedSkillFolderName } from './skillFolderNameUtils';
import { resolveSkillProjectPath } from './skillProjectUtils';
import { SkillReviewDialog } from './SkillReviewDialog';
@ -61,16 +62,6 @@ function parseInitialDescription(detail: SkillDetail | null): string {
return detail?.item.description ?? '';
}
function toSuggestedFolderName(value: string): string {
return value
.normalize('NFKD')
.replace(/[^\x00-\x7F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
export const SkillEditorDialog = ({
open,
mode,
@ -192,7 +183,7 @@ export const SkillEditorDialog = ({
notes: nextNotes,
});
const rawInput = readSkillTemplateContent(nextRawContent);
const suggestedFolderName = toSuggestedFolderName(nextName || 'New Skill');
const suggestedFolderName = toSuggestedSkillFolderName(nextName || 'New Skill');
const hasCustomMarkdown = mode === 'edit' && rawInput.hasUnstructuredBody;
setScope(nextScope);
@ -485,7 +476,7 @@ export const SkillEditorDialog = ({
const nextValue = event.target.value;
setName(nextValue);
if (mode === 'create' && !folderNameEdited) {
setFolderName(toSuggestedFolderName(nextValue || 'New Skill'));
setFolderName(toSuggestedSkillFolderName(nextValue || 'New Skill'));
}
applyFormToRawContent({ name: nextValue });
}}

View file

@ -22,6 +22,7 @@ import {
import { useStore } from '@renderer/store';
import { FileSearch, FolderOpen, X } from 'lucide-react';
import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils';
import { SkillReviewDialog } from './SkillReviewDialog';
import { resolveSkillProjectPath } from './skillProjectUtils';
@ -57,11 +58,6 @@ interface SkillImportDialogProps {
onImported: (skillId: string | null) => void;
}
function getFolderNameFromPath(value: string): string {
const segments = value.split(/[\\/]/u).filter(Boolean);
return segments.at(-1) ?? '';
}
export const SkillImportDialog = ({
open,
projectPath,
@ -101,7 +97,7 @@ export const SkillImportDialog = ({
if (!open || folderNameEdited) {
return;
}
setFolderName(getFolderNameFromPath(sourceDir));
setFolderName(getSuggestedSkillFolderNameFromPath(sourceDir));
}, [folderNameEdited, open, sourceDir]);
useEffect(() => {

View file

@ -0,0 +1,19 @@
export function toSuggestedSkillFolderName(value: string, fallback = 'new-skill'): string {
const normalized = value
.normalize('NFKD')
.replace(/[^\x00-\x7F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return normalized || fallback;
}
export function getSuggestedSkillFolderNameFromPath(
value: string,
fallback = 'imported-skill'
): string {
const segments = value.split(/[\\/]/u).filter(Boolean);
return toSuggestedSkillFolderName(segments.at(-1) ?? '', fallback);
}

View file

@ -188,6 +188,44 @@ describe('SkillImportDialog', () => {
});
});
it('sanitizes the suggested destination folder when the source folder name is not CLI-safe', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
selectFoldersMock.mockResolvedValueOnce(['/tmp/My Skill Folder']);
await act(async () => {
root.render(
React.createElement(SkillImportDialog, {
open: true,
projectPath: null,
projectLabel: null,
onClose: vi.fn(),
onImported: vi.fn(),
})
);
await Promise.resolve();
});
const browseButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.includes('Browse')
) as HTMLButtonElement;
await act(async () => {
browseButton.click();
await Promise.resolve();
});
const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement;
expect(folderInput.value).toBe('my-skill-folder');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('falls back to user scope when the project context disappears mid-dialog', async () => {
const host = document.createElement('div');
document.body.appendChild(host);

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import {
getSuggestedSkillFolderNameFromPath,
toSuggestedSkillFolderName,
} from '../../../../../src/renderer/components/extensions/skills/skillFolderNameUtils';
describe('skillFolderNameUtils', () => {
it('creates a safe slug from a human-readable skill name', () => {
expect(toSuggestedSkillFolderName('Review Helper 2')).toBe('review-helper-2');
});
it('falls back to a safe default when the name cannot be slugged', () => {
expect(toSuggestedSkillFolderName('Привет мир')).toBe('new-skill');
});
it('sanitizes imported folder names from the selected source path', () => {
expect(getSuggestedSkillFolderNameFromPath('/tmp/My Skill Folder')).toBe(
'my-skill-folder'
);
});
it('uses an import-specific fallback when the source folder name is unusable', () => {
expect(getSuggestedSkillFolderNameFromPath('/tmp/技能')).toBe('imported-skill');
});
});