fix(skills): harden rename to managed roots and cover failures

Restrict in-place skill rename to managed skill directories, require
frontmatter name to match before moving, roll back/reject with tests,
hide rename in the UI for unmanaged paths, and drop unused toast keys.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 08:54:29 +00:00
co-authored by Serhii Dziupin
parent f0591515fd
commit bfea13ef1d
17 changed files with 303 additions and 29 deletions
@@ -27,6 +27,7 @@ import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
import { isManagedSkillFilesystemPath } from '@/components/sections/skills/skillLocations';
interface SkillsSidebarProps {
onItemSelect?: () => void;
@@ -35,6 +36,9 @@ interface SkillsSidebarProps {
const BUILT_IN_SKILL_LOCATION = '<built-in>';
const isBuiltInSkill = (skill: DiscoveredSkill | null | undefined): boolean => skill?.path === BUILT_IN_SKILL_LOCATION;
const isRenamableSkill = (skill: DiscoveredSkill | null | undefined): boolean => (
!!skill && !isBuiltInSkill(skill) && isManagedSkillFilesystemPath(skill.path)
);
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
@@ -140,14 +144,14 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
};
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
if (isBuiltInSkill(skill)) return;
if (!isRenamableSkill(skill)) return;
setRenameNewName(skill.name);
setRenameDialogSkill(skill);
};
const handleRenameSkill = async () => {
if (!renameDialogSkill) return;
if (isBuiltInSkill(renameDialogSkill)) {
if (!isRenamableSkill(renameDialogSkill)) {
setRenameDialogSkill(null);
return;
}
@@ -443,13 +447,16 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
: t('settings.skills.sidebar.badge.opencode');
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
const isBuiltIn = isBuiltInSkill(skill);
const canRename = isRenamableSkill(skill);
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const renderMenuItems = (Item: React.ElementType) => (
<>
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</Item>
{canRename ? (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</Item>
) : null}
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDuplicate(); }}>
<Icon name="file-copy" className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
@@ -57,3 +57,22 @@ export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScop
}
return { scope: match.scope, source: match.source };
}
/** True when a discovered skill path is under a managed skill root that rename/delete may mutate. */
export function isManagedSkillFilesystemPath(skillPath: string | null | undefined): boolean {
if (!skillPath || skillPath === '<built-in>') return false;
const normalized = skillPath.replace(/\\/g, '/');
if (
normalized.includes('/.cache/opencode/skills/')
|| normalized.includes('/Caches/opencode/skills/')
|| normalized.includes('/Library/Caches/opencode/skills/')
) {
return false;
}
return (
/\/\.opencode\/skills?\//.test(normalized)
|| /\/\.claude\/skills\//.test(normalized)
|| /\/\.agents\/skills\//.test(normalized)
|| /\/\.config\/opencode\/skills?\//.test(normalized)
);
}
@@ -666,8 +666,6 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" erfolgreich gelöscht',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Skill konnte nicht gelöscht werden',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Skill-Details für Duplizierung konnten nicht geladen werden',
'settings.skills.sidebar.toast.renameLoadFailed': 'Skill-Details konnten nicht geladen werden',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Alter Skill konnte nach Umbenennung nicht entfernt werden',
'settings.skills.sidebar.toast.renameFailed': 'Skill konnte nicht umbenannt werden',
'settings.skills.sidebar.toast.skillRenamed': 'Skill umbenannt in "{name}"',
'settings.skills.sidebar.deleteDialog.title': 'Skill löschen',
@@ -718,8 +718,6 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" deleted successfully',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Failed to delete skill',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Failed to load skill details for duplication',
'settings.skills.sidebar.toast.renameLoadFailed': 'Failed to load skill details',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Failed to remove old skill after rename',
'settings.skills.sidebar.toast.renameFailed': 'Failed to rename skill',
'settings.skills.sidebar.toast.skillRenamed': 'Skill renamed to "{name}"',
'settings.skills.sidebar.deleteDialog.title': 'Delete Skill',
@@ -685,8 +685,6 @@ export const settingsDict = {
"settings.skills.sidebar.toast.skillDeleted": "Habilidad \"{name}\" eliminada con éxito",
"settings.skills.sidebar.toast.deleteSkillFailed": "No se pudo eliminar la habilidad",
"settings.skills.sidebar.toast.duplicateLoadFailed": "No se pudo cargar la información de la habilidad para duplicarla",
"settings.skills.sidebar.toast.renameLoadFailed": "No se pudo cargar la información de la habilidad",
"settings.skills.sidebar.toast.removeOldAfterRenameFailed": "No se pudo eliminar la habilidad antigua después del cambio de nombre",
"settings.skills.sidebar.toast.renameFailed": "No se pudo cambiar el nombre de la habilidad",
"settings.skills.sidebar.toast.skillRenamed": "Habilidad renombrada a \"{name}\"",
"settings.skills.sidebar.deleteDialog.title": "Eliminar habilidad",
@@ -606,8 +606,6 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" supprimé avec succès',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Échec de la suppression du skill',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Échec du chargement des détails du skill pour la duplication',
'settings.skills.sidebar.toast.renameLoadFailed': 'Échec du chargement des détails du skill',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Échec de la suppression de l\'ancien skill après le renommage',
'settings.skills.sidebar.toast.renameFailed': 'Échec du renommage du skill',
'settings.skills.sidebar.toast.skillRenamed': 'Skill renommé en "{name}"',
'settings.skills.sidebar.deleteDialog.title': 'Supprimer le skill',
@@ -718,8 +718,6 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" を削除しました',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Skill の削除に失敗しました',
'settings.skills.sidebar.toast.duplicateLoadFailed': '複製用の Skill 詳細の読み込みに失敗しました',
'settings.skills.sidebar.toast.renameLoadFailed': 'Skill 詳細の読み込みに失敗しました',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '名前変更後に古い Skill の削除に失敗しました',
'settings.skills.sidebar.toast.renameFailed': 'Skill の名前変更に失敗しました',
'settings.skills.sidebar.toast.skillRenamed': 'Skill の名前を「{name}」に変更しました',
'settings.skills.sidebar.deleteDialog.title': 'Skill を削除',
@@ -685,8 +685,6 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': '스킬 "{name}"을 삭제했습니다',
'settings.skills.sidebar.toast.deleteSkillFailed': '스킬을 삭제하지 못했습니다',
'settings.skills.sidebar.toast.duplicateLoadFailed': '복제를 위한 스킬 세부 정보를 로드하지 못했습니다',
'settings.skills.sidebar.toast.renameLoadFailed': '스킬 세부 정보를 로드하지 못했습니다',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '이름 변경 후 이전 스킬을 제거하지 못했습니다',
'settings.skills.sidebar.toast.renameFailed': '스킬 이름을 변경하지 못했습니다',
'settings.skills.sidebar.toast.skillRenamed': '스킬 이름이 "{name}"(으)로 변경되었습니다',
'settings.skills.sidebar.deleteDialog.title': '스킬 삭제',
@@ -1863,9 +1863,7 @@ export const settingsDict = {
'settings.skills.sidebar.title': 'Umiejętności',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Nie udało się usunąć umiejętności',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Nie udało się załadować szczegółów umiejętności do duplikacji',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Nie udało się usunąć starej umiejętności po zmianie nazwy',
'settings.skills.sidebar.toast.renameFailed': 'Nie udało się zmienić nazwy umiejętności',
'settings.skills.sidebar.toast.renameLoadFailed': 'Nie udało się załadować szczegółów umiejętności',
'settings.skills.sidebar.toast.skillDeleted': 'Umiejętność „{name}” została usunięta',
'settings.skills.sidebar.toast.skillRenamed': 'Zmieniono nazwę umiejętności na „{name}”',
'settings.skills.sidebar.total': 'Suma: {count}',
@@ -685,8 +685,6 @@ export const settingsDict = {
"settings.skills.sidebar.toast.skillDeleted": "Habilidade \"{name}\" excluída com sucesso",
"settings.skills.sidebar.toast.deleteSkillFailed": "Não foi possível excluir a habilidade",
"settings.skills.sidebar.toast.duplicateLoadFailed": "Não foi possível carregar as informações da habilidade para duplicá-la",
"settings.skills.sidebar.toast.renameLoadFailed": "Não foi possível carregar as informações da habilidade",
"settings.skills.sidebar.toast.removeOldAfterRenameFailed": "Não foi possível excluir a habilidade antiga depois da renomeação",
"settings.skills.sidebar.toast.renameFailed": "Não foi possível renomear da habilidade",
"settings.skills.sidebar.toast.skillRenamed": "Habilidade renomeada para \"{name}\"",
"settings.skills.sidebar.deleteDialog.title": "Excluir habilidade",
@@ -685,8 +685,6 @@ export const settingsDict = {
"settings.skills.sidebar.toast.skillDeleted": "Навичку \"{name}\" успішно видалено",
"settings.skills.sidebar.toast.deleteSkillFailed": "Не вдалося видалити навичку",
"settings.skills.sidebar.toast.duplicateLoadFailed": "Не вдалося завантажити деталі навичок для дублювання",
"settings.skills.sidebar.toast.renameLoadFailed": "Не вдалося завантажити деталі навичок",
"settings.skills.sidebar.toast.removeOldAfterRenameFailed": "Не вдалося видалити стару навичку після перейменування",
"settings.skills.sidebar.toast.renameFailed": "Не вдалося перейменувати навичку",
"settings.skills.sidebar.toast.skillRenamed": "Навичку перейменовано на \"{name}\"",
"settings.skills.sidebar.deleteDialog.title": "Видалити навичку",
@@ -685,8 +685,6 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': '技能“{name}”已删除',
'settings.skills.sidebar.toast.deleteSkillFailed': '删除技能失败',
'settings.skills.sidebar.toast.duplicateLoadFailed': '加载技能详情以复制失败',
'settings.skills.sidebar.toast.renameLoadFailed': '加载技能详情失败',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '重命名后移除旧技能失败',
'settings.skills.sidebar.toast.renameFailed': '重命名技能失败',
'settings.skills.sidebar.toast.skillRenamed': '技能已重命名为“{name}”',
'settings.skills.sidebar.deleteDialog.title': '删除技能',
@@ -682,8 +682,6 @@
'settings.skills.sidebar.toast.skillDeleted': 'skill「{name}」已刪除',
'settings.skills.sidebar.toast.deleteSkillFailed': '刪除 skill 失敗',
'settings.skills.sidebar.toast.duplicateLoadFailed': '複製 skill 的詳細資訊載入失敗',
'settings.skills.sidebar.toast.renameLoadFailed': '載入 skill 詳情失敗',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '重新命名後移除舊 skill 失敗',
'settings.skills.sidebar.toast.renameFailed': '重新命名 skill 失敗',
'settings.skills.sidebar.toast.skillRenamed': 'skill 已重新命名為「{name}」',
'settings.skills.sidebar.deleteDialog.title': '刪除 Skill',