From 86bc4ce4e8ce3738ee44c56dc322a8530ed42cd7 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 23:08:00 +0300 Subject: [PATCH] fix(extensions): sanitize suggested skill folder names --- .../extensions/skills/SkillEditorDialog.tsx | 15 ++------ .../extensions/skills/SkillImportDialog.tsx | 8 +--- .../extensions/skills/skillFolderNameUtils.ts | 19 ++++++++++ .../skills/SkillImportDialog.test.ts | 38 +++++++++++++++++++ .../skills/skillFolderNameUtils.test.ts | 26 +++++++++++++ 5 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 src/renderer/components/extensions/skills/skillFolderNameUtils.ts create mode 100644 test/renderer/components/extensions/skills/skillFolderNameUtils.test.ts diff --git a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx index 1b0cb3a3..bf2e9f1d 100644 --- a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx @@ -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 }); }} diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx index dd30d71e..646bc760 100644 --- a/src/renderer/components/extensions/skills/SkillImportDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx @@ -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(() => { diff --git a/src/renderer/components/extensions/skills/skillFolderNameUtils.ts b/src/renderer/components/extensions/skills/skillFolderNameUtils.ts new file mode 100644 index 00000000..024d4eaf --- /dev/null +++ b/src/renderer/components/extensions/skills/skillFolderNameUtils.ts @@ -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); +} diff --git a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts index d1be6e25..367c2fac 100644 --- a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts +++ b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts @@ -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); diff --git a/test/renderer/components/extensions/skills/skillFolderNameUtils.test.ts b/test/renderer/components/extensions/skills/skillFolderNameUtils.test.ts new file mode 100644 index 00000000..69450fe5 --- /dev/null +++ b/test/renderer/components/extensions/skills/skillFolderNameUtils.test.ts @@ -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'); + }); +});