diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx index 0b13c6e0..f440b9a5 100644 --- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx +++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx @@ -35,6 +35,9 @@ interface SkillsSidebarProps { const BUILT_IN_SKILL_LOCATION = ''; const isBuiltInSkill = (skill: DiscoveredSkill | null | undefined): boolean => skill?.path === BUILT_IN_SKILL_LOCATION; +const isRenamableSkill = (skill: DiscoveredSkill | null | undefined): boolean => ( + !!skill && !isBuiltInSkill(skill) && skill.renamable === true +); export const SkillsSidebar: React.FC = ({ onItemSelect }) => { const { t } = useI18n(); @@ -49,16 +52,16 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) => skills, setSelectedSkill, setSkillDraft, - createSkill, deleteSkill, + renameSkill, getSkillDetail, } = useSkillsStore(useShallow((s) => ({ selectedSkillName: s.selectedSkillName, skills: s.skills, setSelectedSkill: s.setSelectedSkill, setSkillDraft: s.setSkillDraft, - createSkill: s.createSkill, deleteSkill: s.deleteSkill, + renameSkill: s.renameSkill, getSkillDetail: s.getSkillDetail, }))); @@ -140,14 +143,14 @@ export const SkillsSidebar: React.FC = ({ 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; } @@ -169,31 +172,11 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) => return; } - // Get full detail to copy - const detail = await getSkillDetail(renameDialogSkill.name); - if (!detail) { - toast.error(t('settings.skills.sidebar.toast.renameLoadFailed')); - setRenameDialogSkill(null); - return; - } - - // Create new skill with new name - const success = await createSkill({ - name: sanitizedName, - description: 'Renamed skill', // Will need proper description - scope: renameDialogSkill.scope, - source: renameDialogSkill.source, - }); - + // Rename in place on disk so SKILL.md body and supporting files are preserved. + const success = await renameSkill(renameDialogSkill.name, sanitizedName); if (success) { - // Delete old skill - const deleteSuccess = await deleteSkill(renameDialogSkill.name); - if (deleteSuccess) { - toast.success(`Skill renamed to "${sanitizedName}"`); - setSelectedSkill(sanitizedName); - } else { - toast.error(t('settings.skills.sidebar.toast.removeOldAfterRenameFailed')); - } + toast.success(t('settings.skills.sidebar.toast.skillRenamed', { name: sanitizedName })); + setSelectedSkill(sanitizedName); } else { toast.error(t('settings.skills.sidebar.toast.renameFailed')); } @@ -463,13 +446,16 @@ const SkillListItem: React.FC = ({ : 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) => ( <> - { e.stopPropagation(); onRename(); }}> - - {t('settings.common.actions.rename')} - + {canRename ? ( + { e.stopPropagation(); onRename(); }}> + + {t('settings.common.actions.rename')} + + ) : null} { e.stopPropagation(); onDuplicate(); }}> {t('settings.common.actions.duplicate')} diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 1291cf6d..2bf271a5 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -666,9 +666,8 @@ 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', 'settings.skills.sidebar.deleteDialog.description': 'Möchten Sie den Skill "{name}" wirklich löschen?', 'settings.skills.sidebar.renameDialog.title': 'Skill umbenennen', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 924965d1..6261cb9c 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -718,9 +718,8 @@ 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', 'settings.skills.sidebar.deleteDialog.description': 'Are you sure you want to delete skill "{name}"?', 'settings.skills.sidebar.renameDialog.title': 'Rename Skill', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 88f12bfe..7f13e2e3 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -685,9 +685,8 @@ 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", "settings.skills.sidebar.deleteDialog.description": "¿Estás seguro de que quieres eliminar la habilidad \"{name}\"?", "settings.skills.sidebar.renameDialog.title": "Cambiar nombre habilidad", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 30cfe251..d0034fa8 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -606,9 +606,8 @@ 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', 'settings.skills.sidebar.deleteDialog.description': 'Êtes-vous sûr de vouloir supprimer le skill « {name} » ?', 'settings.skills.sidebar.renameDialog.title': 'Renommer le skill', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 6ad20690..f61974f8 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -718,9 +718,8 @@ 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 を削除', 'settings.skills.sidebar.deleteDialog.description': 'Skill "{name}" を削除してもよろしいですか?', 'settings.skills.sidebar.renameDialog.title': 'Skill の名前変更', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index a46f1b2e..54f276d4 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -685,9 +685,8 @@ 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': '스킬 삭제', 'settings.skills.sidebar.deleteDialog.description': '스킬 "{name}"을 삭제하시겠습니까?', 'settings.skills.sidebar.renameDialog.title': '스킬 이름 변경', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 93a9e901..c0ac580c 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1922,10 +1922,9 @@ 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}', 'settings.usage.pace.prediction': 'Prognoza: {prediction}', 'settings.usage.pace.predictionLabel': 'Prognoza: ', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index eebdc398..19dc9b39 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -685,9 +685,8 @@ 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", "settings.skills.sidebar.deleteDialog.description": "Tem certeza de que deseja excluir a habilidade \"{name}\"?", "settings.skills.sidebar.renameDialog.title": "Renomear habilidade", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index b7f18d2e..a7325ccb 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -685,9 +685,8 @@ 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": "Видалити навичку", "settings.skills.sidebar.deleteDialog.description": "Ви впевнені, що бажаєте видалити навичку «{name}»?", "settings.skills.sidebar.renameDialog.title": "Перейменувати навичку", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 8fe889c7..adc5a8b6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -685,9 +685,8 @@ 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': '删除技能', 'settings.skills.sidebar.deleteDialog.description': '确定要删除技能“{name}”吗?', 'settings.skills.sidebar.renameDialog.title': '重命名技能', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index a7405089..24571ef4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -682,9 +682,8 @@ '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', 'settings.skills.sidebar.deleteDialog.description': '確定要刪除 skill「{name}」嗎?', 'settings.skills.sidebar.renameDialog.title': '重新命名 Skill', diff --git a/packages/ui/src/stores/useSkillsStore.test.ts b/packages/ui/src/stores/useSkillsStore.test.ts index f75fb711..3c569d98 100644 --- a/packages/ui/src/stores/useSkillsStore.test.ts +++ b/packages/ui/src/stores/useSkillsStore.test.ts @@ -98,9 +98,94 @@ describe('useSkillsStore directory resolution', () => { source: 'agents', description: 'Repository local', group: undefined, + renamable: false, }]); }); + test('loadSkills maps authoritative renamable from the list response', async () => { + runtimeFetchImpl = async () => new Response(JSON.stringify({ + skills: [ + { + name: 'managed-skill', + path: `${activeProjectPath}/.opencode/skills/managed-skill/SKILL.md`, + scope: 'project', + source: 'opencode', + renamable: true, + sources: { md: { description: 'Managed' } }, + }, + { + name: 'cache-skill', + path: '/home/ubuntu/.cache/opencode/skills/hash/cache-skill/SKILL.md', + scope: 'user', + source: 'opencode', + renamable: false, + sources: { md: { description: 'Cache' } }, + }, + ], + }), { + headers: { 'Content-Type': 'application/json' }, + }); + + expect(await useSkillsStore.getState().loadSkills()).toBe(true); + expect(useSkillsStore.getState().skills).toEqual([ + { + name: 'managed-skill', + path: `${activeProjectPath}/.opencode/skills/managed-skill/SKILL.md`, + scope: 'project', + source: 'opencode', + description: 'Managed', + group: undefined, + renamable: true, + }, + { + name: 'cache-skill', + path: '/home/ubuntu/.cache/opencode/skills/hash/cache-skill/SKILL.md', + scope: 'user', + source: 'opencode', + description: 'Cache', + group: 'hash', + renamable: false, + }, + ]); + }); + + test('renameSkill uses getRequestDirectory query and x-opencode-directory header', async () => { + runtimeFetchImpl = async (_url, init) => { + if (init?.method === 'PATCH') { + return new Response(JSON.stringify({ + success: true, + requiresReload: false, + }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ + skills: [{ + name: 'new-skill', + path: `${activeProjectPath}/.opencode/skills/new-skill/SKILL.md`, + scope: 'project', + source: 'opencode', + renamable: true, + sources: { md: { description: 'Renamed' } }, + }], + }), { + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const renamed = await useSkillsStore.getState().renameSkill('old-skill', 'new-skill'); + expect(renamed).toBe(true); + + const renameCall = runtimeFetchCalls.find((call) => String(call.url).includes('/api/config/skills/old-skill')); + expect(renameCall).toBeTruthy(); + expect(renameCall?.url).toContain(`directory=${encodeURIComponent(activeProjectPath)}`); + + const headers = new Headers(renameCall?.headers); + expect(headers.get('content-type')).toBe('application/json'); + expect(headers.get('x-opencode-directory')).toBe(activeProjectPath); + }); + test('invalidateSkillsLoadCache() with no argument clears the active-project cache key used by loadSkills', async () => { expect(await useSkillsStore.getState().loadSkills()).toBe(true); expect(runtimeFetchCalls.length).toBe(1); diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index eab17af2..2157db21 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -76,6 +76,8 @@ export interface DiscoveredSkill { description?: string; /** Domain folder parsed from file path, e.g. "automation-ai", "lark-ecosystem" */ group?: string; + /** Authoritative server flag: skill lives under a managed root and can be renamed in place. */ + renamable?: boolean; } /** Parse the domain group folder from a skill file path. @@ -99,6 +101,7 @@ interface RawSkillResponse { path: string; scope?: SkillScope; source?: SkillSource; + renamable?: boolean; sources?: { md?: { description?: string; @@ -149,6 +152,7 @@ interface SkillsStore { getSkillDetail: (name: string) => Promise; createSkill: (config: SkillConfig) => Promise; updateSkill: (name: string, config: Partial) => Promise; + renameSkill: (name: string, newName: string) => Promise; deleteSkill: (name: string) => Promise; getSkillByName: (name: string) => DiscoveredSkill | undefined; @@ -245,6 +249,7 @@ export const useSkillsStore = create()( source: s.source ?? 'opencode', description: s.sources?.md?.description || '', group: parseSkillGroup(s.path), + renamable: s.renamable === true, })); set({ skills: configSkills, isLoading: false }); @@ -399,6 +404,53 @@ export const useSkillsStore = create()( } }, + renameSkill: async (name: string, newName: string) => { + startConfigUpdate("Renaming skill..."); + let requiresReload = false; + try { + const directory = getRequestDirectory(); + const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; + + const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + ...(directory ? { 'x-opencode-directory': directory } : {}), + }, + body: JSON.stringify({ renameTo: newName }), + }); + + const payload = await response.json().catch(() => null); + if (!response.ok) { + const message = payload?.error || 'Failed to rename skill'; + throw new Error(message); + } + + const needsReload = payload?.requiresReload ?? false; + invalidateSkillsLoadCache(directory); + if (needsReload) { + requiresReload = true; + await refreshSkillsAfterOpenCodeRestart({ + message: payload?.message, + delayMs: payload?.reloadDelayMs, + }); + return true; + } + + const loaded = await get().loadSkills(); + if (loaded) { + emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); + } + return loaded; + } catch { + return false; + } finally { + if (!requiresReload) { + finishConfigUpdate(); + } + } + }, + deleteSkill: async (name: string) => { startConfigUpdate("Deleting skill..."); let requiresReload = false; diff --git a/packages/vscode/src/bridge-config-runtime.ts b/packages/vscode/src/bridge-config-runtime.ts index 361f2a1a..0ec36bee 100644 --- a/packages/vscode/src/bridge-config-runtime.ts +++ b/packages/vscode/src/bridge-config-runtime.ts @@ -25,6 +25,8 @@ import { createSkill, updateSkill, deleteSkill, + renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -652,7 +654,21 @@ export async function handleConfigBridgeMessage( if (!name && normalizedMethod === 'GET') { const skills = await resolveDiscoveredSkills(deps, ctx, workingDirectory); - return { id, type, success: true, data: { skills } }; + return { + id, + type, + success: true, + data: { + skills: skills.map((skill) => ({ + ...skill, + renamable: Boolean( + skill.path + && skill.path !== '' + && isManagedSkillPath(skill.path, workingDirectory) + ), + })), + }, + }; } const skillName = typeof name === 'string' ? name.trim() : ''; @@ -693,6 +709,24 @@ export async function handleConfigBridgeMessage( } if (normalizedMethod === 'PATCH') { + if (typeof body?.renameTo === 'string') { + const newName = body.renameTo.trim(); + renameSkill(skillName, newName, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + name: newName, + requiresReload: true, + message: `Skill renamed to ${newName} successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + updateSkill(skillName, (body || {}) as Record, workingDirectory); await ctx?.manager?.restart(); return { diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 4771d98a..a14ef031 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -2918,7 +2918,7 @@ export const updateSkill = (skillName: string, updates: Record, let mdModified = false; for (const [field, value] of Object.entries(updates || {})) { - if (field === 'scope') continue; + if (field === 'scope' || field === 'source' || field === 'targetPath' || field === 'renameTo') continue; if (field === 'instructions') { const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value); @@ -2990,3 +2990,123 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void throw new Error(`Skill "${skillName}" not found`); } }; + +const isPathInside = (candidatePath: string, parentPath: string): boolean => { + const resolvedCandidate = path.resolve(candidatePath); + const resolvedParent = path.resolve(parentPath); + return resolvedCandidate === resolvedParent + || resolvedCandidate.startsWith(`${resolvedParent}${path.sep}`); +}; + +const getManagedSkillRoots = (workingDirectory?: string): string[] => { + const roots: string[] = []; + const pushRoot = (dir?: string | null) => { + if (!dir) return; + const resolved = path.resolve(dir); + if (!roots.includes(resolved)) { + roots.push(resolved); + } + }; + + pushRoot(SKILL_DIR); + pushRoot(path.join(OPENCODE_CONFIG_DIR, 'skill')); + pushRoot(path.join(os.homedir(), '.opencode', 'skills')); + pushRoot(path.join(os.homedir(), '.opencode', 'skill')); + pushRoot(path.join(os.homedir(), '.claude', 'skills')); + pushRoot(path.join(os.homedir(), '.agents', 'skills')); + + const customConfigDir = process.env.OPENCODE_CONFIG_DIR + ? path.resolve(process.env.OPENCODE_CONFIG_DIR) + : null; + pushRoot(customConfigDir ? path.join(customConfigDir, 'skills') : null); + pushRoot(customConfigDir ? path.join(customConfigDir, 'skill') : null); + + if (workingDirectory) { + const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory); + for (const ancestor of getAncestors(workingDirectory, worktreeRoot)) { + pushRoot(path.join(ancestor, '.opencode', 'skills')); + pushRoot(path.join(ancestor, '.opencode', 'skill')); + pushRoot(path.join(ancestor, '.claude', 'skills')); + pushRoot(path.join(ancestor, '.agents', 'skills')); + } + } + + return roots; +}; + +const isManagedSkillPath = (skillMdPath: string, workingDirectory?: string): boolean => { + if (!skillMdPath || skillMdPath === BUILT_IN_SKILL_LOCATION) { + return false; + } + const skillDir = path.dirname(path.resolve(skillMdPath)); + return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root)); +}; + +export { isManagedSkillPath }; + +export const renameSkill = (oldName: string, newName: string, workingDirectory?: string): void => { + ensureSkillDirs(); + validateSkillName(newName); + + if (oldName === newName) { + return; + } + + const existing = getSkillScope(oldName, workingDirectory); + if (!existing.path) { + throw new Error(`Skill "${oldName}" not found`); + } + if (existing.path === BUILT_IN_SKILL_LOCATION || !fs.existsSync(existing.path)) { + throw new Error(`Skill "${oldName}" cannot be renamed`); + } + if (path.basename(existing.path) !== 'SKILL.md') { + throw new Error(`Skill "${oldName}" target must be a SKILL.md file`); + } + if (!isManagedSkillPath(existing.path, workingDirectory)) { + throw new Error(`Skill "${oldName}" is outside managed skill directories and cannot be renamed`); + } + + const mdDataBeforeMove = parseMdFile(existing.path); + const frontmatterName = typeof mdDataBeforeMove.frontmatter?.name === 'string' + ? mdDataBeforeMove.frontmatter.name + : oldName; + if (frontmatterName !== oldName) { + throw new Error(`Skill "${oldName}" does not match ${existing.path}`); + } + + const conflict = getSkillScope(newName, workingDirectory); + if (conflict.path) { + throw new Error(`Skill ${newName} already exists at ${conflict.path}`); + } + + const oldDir = path.dirname(existing.path); + const newDir = path.join(path.dirname(oldDir), newName); + const directoriesDiffer = path.resolve(oldDir) !== path.resolve(newDir); + + if (directoriesDiffer && fs.existsSync(newDir)) { + throw new Error(`Skill directory already exists at ${newDir}`); + } + + if (directoriesDiffer) { + fs.renameSync(oldDir, newDir); + } + + const newPath = path.join(newDir, 'SKILL.md'); + try { + const mdData = parseMdFile(newPath); + mdData.frontmatter = { + ...mdData.frontmatter, + name: newName, + }; + writeMdFile(newPath, mdData.frontmatter, mdData.body); + } catch (error) { + if (directoriesDiffer && fs.existsSync(newDir) && !fs.existsSync(oldDir)) { + try { + fs.renameSync(newDir, oldDir); + } catch { + // Best-effort rollback; surface the original write failure. + } + } + throw error; + } +}; diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index b618e811..68c33dc9 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -358,6 +358,8 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`. ## Public exports (skill-routes.js) - `registerSkillRoutes(app, dependencies)`: registers skills-related routes: - Skills config CRUD and metadata under `/api/config/skills*` + - Skill rename via `PATCH /api/config/skills/:name` with `{ renameTo }` (directory rename preserves `SKILL.md` body and supporting files; restricted to managed skill roots under `.opencode/skills|skill`, `.claude/skills`, and `.agents/skills`) + - Skill list responses include authoritative `renamable` derived from the same managed-root policy used by rename - Skills catalog listing/source pagination, scan, and install routes - Supporting skill file read/write/delete routes - Directory resolution prefers an explicit request directory, then soft-falls diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 803c742e..bf05007b 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -38,7 +38,7 @@ import { decodePluginId, } from './plugins.js'; import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js'; -import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill } from './skills.js'; +import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill, renameSkill, isManagedSkillPath } from './skills.js'; import { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js'; import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js'; import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js'; @@ -257,6 +257,8 @@ export const createFeatureRoutesRuntime = (dependencies) => { createSkill, updateSkill, deleteSkill, + renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, diff --git a/packages/web/server/lib/opencode/skill-routes.js b/packages/web/server/lib/opencode/skill-routes.js index ad8dae59..f06bef5f 100644 --- a/packages/web/server/lib/opencode/skill-routes.js +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -21,6 +21,8 @@ export const registerSkillRoutes = (app, dependencies) => { createSkill, updateSkill, deleteSkill, + renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -236,9 +238,15 @@ export const registerSkillRoutes = (app, dependencies) => { const enrichedSkills = skills.map((skill) => { const sources = getSkillSources(skill.name, directory, skill); + const skillPath = typeof skill.path === 'string' ? skill.path : null; return { ...skill, - sources + sources, + renamable: Boolean( + skillPath + && skillPath !== '' + && isManagedSkillPath(skillPath, directory) + ), }; }); @@ -635,6 +643,22 @@ export const registerSkillRoutes = (app, dependencies) => { return res.status(400).json({ error }); } + if (typeof updates?.renameTo === 'string') { + const newName = updates.renameTo.trim(); + console.log(`[Server] Renaming skill: ${skillName} -> ${newName}`); + console.log('[Server] Working directory:', directory); + renameSkill(skillName, newName, directory); + await refreshOpenCodeAfterConfigChange('skill rename'); + + return res.json({ + success: true, + name: newName, + requiresReload: true, + message: `Skill renamed to ${newName} successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } + console.log(`[Server] Updating skill: ${skillName}`); console.log('[Server] Working directory:', directory); diff --git a/packages/web/server/lib/opencode/skill-routes.test.js b/packages/web/server/lib/opencode/skill-routes.test.js index c83f7f84..3ba8526e 100644 --- a/packages/web/server/lib/opencode/skill-routes.test.js +++ b/packages/web/server/lib/opencode/skill-routes.test.js @@ -9,7 +9,9 @@ import { deleteSkill, discoverSkills, getSkillSources, + isManagedSkillPath, mergeDiscoveredSkills, + renameSkill, updateSkill, } from './skills.js'; import { @@ -58,6 +60,8 @@ const startSkillsApp = ({ projectRoot }) => { createSkill, updateSkill, deleteSkill, + renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -154,4 +158,62 @@ describe('skill-routes directory soft fallback', () => { const payload = await listResponse.json(); expect(payload.skills.map((skill) => skill.name)).toContain('manual-repo-skill'); }); + + it('marks managed-root skills renamable and cache skills not renamable', async () => { + projectRoot = createTempProject(); + const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-list-skill'); + fs.mkdirSync(managedDir, { recursive: true }); + fs.writeFileSync( + path.join(managedDir, 'SKILL.md'), + [ + '---', + 'name: managed-list-skill', + 'description: Managed list skill', + '---', + '', + 'Managed body', + '', + ].join('\n'), + 'utf8', + ); + + const cacheStamp = `oc-skill-routes-${Date.now()}`; + const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-list-skill'); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync( + path.join(cacheDir, 'SKILL.md'), + [ + '---', + 'name: cache-list-skill', + 'description: Cache list skill', + '---', + '', + 'Cache body', + '', + ].join('\n'), + 'utf8', + ); + + try { + appHandle = startSkillsApp({ projectRoot }); + const listResponse = await fetch( + `${appHandle.baseUrl}/api/config/skills?directory=${encodeURIComponent(projectRoot)}`, + ); + expect(listResponse.status).toBe(200); + const payload = await listResponse.json(); + + const managed = payload.skills.find((entry) => entry.name === 'managed-list-skill'); + const cached = payload.skills.find((entry) => entry.name === 'cache-list-skill'); + + expect(managed).toBeTruthy(); + expect(managed.renamable).toBe(true); + expect(cached).toBeTruthy(); + expect(cached.renamable).toBe(false); + } finally { + fs.rmSync(path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp), { + recursive: true, + force: true, + }); + } + }); }); diff --git a/packages/web/server/lib/opencode/skills.js b/packages/web/server/lib/opencode/skills.js index 91027b52..9ee24d6b 100644 --- a/packages/web/server/lib/opencode/skills.js +++ b/packages/web/server/lib/opencode/skills.js @@ -412,12 +412,22 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) { return sources; } -function createSkill(skillName, config, workingDirectory, scope) { - ensureDirs(); +function isValidSkillName(skillName) { + return typeof skillName === 'string' + && skillName.length > 0 + && skillName.length <= 64 + && /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName); +} - if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) { +function assertValidSkillName(skillName) { + if (!isValidSkillName(skillName)) { throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`); } +} + +function createSkill(skillName, config, workingDirectory, scope) { + ensureDirs(); + assertValidSkillName(skillName); const existing = getSkillScope(skillName, workingDirectory); if (existing.path) { @@ -505,7 +515,7 @@ function updateSkill(skillName, updates, workingDirectory, targetPath = null) { let mdModified = false; for (const [field, value] of Object.entries(updates)) { - if (field === 'scope' || field === 'source' || field === 'targetPath') { + if (field === 'scope' || field === 'source' || field === 'targetPath' || field === 'renameTo') { continue; } @@ -592,6 +602,130 @@ function deleteSkill(skillName, workingDirectory) { } } +function isPathInside(candidatePath, parentPath) { + if (!candidatePath || !parentPath) return false; + const resolvedCandidate = path.resolve(candidatePath); + const resolvedParent = path.resolve(parentPath); + return resolvedCandidate === resolvedParent + || resolvedCandidate.startsWith(`${resolvedParent}${path.sep}`); +} + +function getManagedSkillRoots(workingDirectory) { + const roots = []; + const pushRoot = (dir) => { + if (!dir) return; + const resolved = path.resolve(dir); + if (!roots.includes(resolved)) { + roots.push(resolved); + } + }; + + pushRoot(SKILL_DIR); + pushRoot(path.join(OPENCODE_CONFIG_DIR, 'skill')); + pushRoot(path.join(os.homedir(), '.opencode', 'skills')); + pushRoot(path.join(os.homedir(), '.opencode', 'skill')); + pushRoot(path.join(os.homedir(), '.claude', 'skills')); + pushRoot(path.join(os.homedir(), '.agents', 'skills')); + + const customConfigDir = process.env.OPENCODE_CONFIG_DIR + ? path.resolve(process.env.OPENCODE_CONFIG_DIR) + : null; + if (customConfigDir) { + pushRoot(path.join(customConfigDir, 'skills')); + pushRoot(path.join(customConfigDir, 'skill')); + } + + if (workingDirectory) { + const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory); + for (const ancestor of getAncestors(workingDirectory, worktreeRoot)) { + pushRoot(path.join(ancestor, '.opencode', 'skills')); + pushRoot(path.join(ancestor, '.opencode', 'skill')); + pushRoot(path.join(ancestor, '.claude', 'skills')); + pushRoot(path.join(ancestor, '.agents', 'skills')); + } + } + + return roots; +} + +function isManagedSkillPath(skillMdPath, workingDirectory) { + if (!skillMdPath || skillMdPath === BUILT_IN_SKILL_LOCATION) { + return false; + } + const skillDir = path.dirname(path.resolve(skillMdPath)); + return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root)); +} + +function renameSkill(oldName, newName, workingDirectory) { + ensureDirs(); + assertValidSkillName(newName); + + if (oldName === newName) { + return; + } + + const existing = getSkillScope(oldName, workingDirectory); + if (!existing.path) { + throw new Error(`Skill "${oldName}" not found`); + } + if (existing.path === BUILT_IN_SKILL_LOCATION || !fs.existsSync(existing.path)) { + throw new Error(`Skill "${oldName}" cannot be renamed`); + } + if (path.basename(existing.path) !== 'SKILL.md') { + throw new Error(`Skill "${oldName}" target must be a SKILL.md file`); + } + if (!isManagedSkillPath(existing.path, workingDirectory)) { + throw new Error(`Skill "${oldName}" is outside managed skill directories and cannot be renamed`); + } + + const mdDataBeforeMove = parseMdFile(existing.path); + const frontmatterName = typeof mdDataBeforeMove.frontmatter?.name === 'string' + ? mdDataBeforeMove.frontmatter.name + : oldName; + if (frontmatterName !== oldName) { + throw new Error(`Skill "${oldName}" does not match ${existing.path}`); + } + + const conflict = getSkillScope(newName, workingDirectory); + if (conflict.path) { + throw new Error(`Skill ${newName} already exists at ${conflict.path}`); + } + + const oldDir = path.dirname(existing.path); + const newDir = path.join(path.dirname(oldDir), newName); + const directoriesDiffer = path.resolve(oldDir) !== path.resolve(newDir); + + if (directoriesDiffer && fs.existsSync(newDir)) { + throw new Error(`Skill directory already exists at ${newDir}`); + } + + // Rename the skill directory in place so supporting files and SKILL.md body are preserved. + if (directoriesDiffer) { + fs.renameSync(oldDir, newDir); + } + + const newPath = path.join(newDir, 'SKILL.md'); + try { + const mdData = parseMdFile(newPath); + mdData.frontmatter = { + ...mdData.frontmatter, + name: newName, + }; + writeMdFile(newPath, mdData.frontmatter, mdData.body); + } catch (error) { + if (directoriesDiffer && fs.existsSync(newDir) && !fs.existsSync(oldDir)) { + try { + fs.renameSync(newDir, oldDir); + } catch (rollbackError) { + console.error(`Failed to rollback skill rename from ${newDir} to ${oldDir}:`, rollbackError); + } + } + throw error; + } + + console.log(`Renamed skill: ${oldName} -> ${newName} (path: ${newPath})`); +} + export { getSkillSources, discoverSkills, @@ -599,4 +733,6 @@ export { createSkill, updateSkill, deleteSkill, + renameSkill, + isManagedSkillPath, }; diff --git a/packages/web/server/lib/opencode/skills.test.js b/packages/web/server/lib/opencode/skills.test.js index ffa3a6f2..975f3f79 100644 --- a/packages/web/server/lib/opencode/skills.test.js +++ b/packages/web/server/lib/opencode/skills.test.js @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; +import fs from 'fs'; import fsPromises from 'fs/promises'; import os from 'os'; import path from 'path'; -import { discoverSkills, getSkillSources, mergeDiscoveredSkills } from './skills.js'; +import { discoverSkills, getSkillSources, mergeDiscoveredSkills, renameSkill } from './skills.js'; describe('skills', () => { it('merges locally discovered skills missing from OpenCode live discovery', () => { @@ -147,4 +148,198 @@ describe('skills', () => { await fsPromises.rm(tempRoot, { recursive: true, force: true }); } }); + + it('renames a skill directory while preserving SKILL.md body and supporting files', async () => { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-')); + const projectRoot = path.join(tempRoot, 'project'); + const skillDir = path.join(projectRoot, '.opencode', 'skills', 'original-skill'); + const skillPath = path.join(skillDir, 'SKILL.md'); + const supportPath = path.join(skillDir, 'notes.md'); + const body = [ + '# Original Skill', + '', + 'Preserve this non-trivial body across rename.', + '', + '## Details', + '', + '- step one', + '- step two', + ].join('\n'); + + try { + await fsPromises.mkdir(skillDir, { recursive: true }); + await fsPromises.writeFile( + skillPath, + [ + '---', + 'name: original-skill', + 'description: Original skill description', + 'license: MIT', + '---', + '', + body, + '', + ].join('\n'), + 'utf8', + ); + await fsPromises.writeFile(supportPath, 'supporting file contents\n', 'utf8'); + + renameSkill('original-skill', 'renamed-skill', projectRoot); + + const renamedDir = path.join(projectRoot, '.opencode', 'skills', 'renamed-skill'); + const renamedPath = path.join(renamedDir, 'SKILL.md'); + const renamedSupportPath = path.join(renamedDir, 'notes.md'); + + expect(fs.existsSync(skillDir)).toBe(false); + expect(fs.existsSync(renamedPath)).toBe(true); + expect(fs.existsSync(renamedSupportPath)).toBe(true); + + const sources = getSkillSources('renamed-skill', projectRoot, { + name: 'renamed-skill', + path: renamedPath, + scope: 'project', + source: 'opencode', + description: 'fallback', + }); + + expect(sources.md.exists).toBe(true); + expect(sources.md.name).toBe('renamed-skill'); + expect(sources.md.description).toBe('Original skill description'); + expect(sources.md.instructions).toBe(body); + expect(await fsPromises.readFile(renamedSupportPath, 'utf8')).toBe('supporting file contents\n'); + + const raw = await fsPromises.readFile(renamedPath, 'utf8'); + expect(raw).toContain('license: MIT'); + expect(raw).not.toContain('Renamed skill'); + } finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('rolls back the directory rename when frontmatter write fails', async () => { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-rollback-')); + const projectRoot = path.join(tempRoot, 'project'); + const skillDir = path.join(projectRoot, '.opencode', 'skills', 'rollback-skill'); + const skillPath = path.join(skillDir, 'SKILL.md'); + const body = '# Rollback body\n\nMust remain in the original directory.'; + + try { + await fsPromises.mkdir(skillDir, { recursive: true }); + await fsPromises.writeFile( + skillPath, + [ + '---', + 'name: rollback-skill', + 'description: Rollback skill', + '---', + '', + body, + '', + ].join('\n'), + 'utf8', + ); + await fsPromises.chmod(skillPath, 0o444); + + expect(() => renameSkill('rollback-skill', 'rollback-skill-renamed', projectRoot)).toThrow(); + + expect(fs.existsSync(skillDir)).toBe(true); + expect(fs.existsSync(path.join(projectRoot, '.opencode', 'skills', 'rollback-skill-renamed'))).toBe(false); + expect(await fsPromises.readFile(skillPath, 'utf8')).toContain(body); + } finally { + try { + await fsPromises.chmod(skillPath, 0o644); + } catch { + // Best-effort cleanup when the file was rolled back under a different mode. + } + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('rejects invalid names, missing skills, conflicts, unmanaged paths, and frontmatter mismatches', async () => { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-reject-')); + const projectRoot = path.join(tempRoot, 'project'); + const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-skill'); + const conflictDir = path.join(projectRoot, '.opencode', 'skills', 'taken-name'); + const mismatchDir = path.join(projectRoot, '.opencode', 'skills', 'folder-name'); + const cacheStamp = `oc-rename-${Date.now()}`; + const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-skill'); + + try { + await fsPromises.mkdir(managedDir, { recursive: true }); + await fsPromises.writeFile( + path.join(managedDir, 'SKILL.md'), + [ + '---', + 'name: managed-skill', + 'description: Managed', + '---', + '', + 'Managed body', + '', + ].join('\n'), + 'utf8', + ); + + await fsPromises.mkdir(conflictDir, { recursive: true }); + await fsPromises.writeFile( + path.join(conflictDir, 'SKILL.md'), + [ + '---', + 'name: taken-name', + 'description: Taken', + '---', + '', + 'Taken body', + '', + ].join('\n'), + 'utf8', + ); + + await fsPromises.mkdir(mismatchDir, { recursive: true }); + await fsPromises.writeFile( + path.join(mismatchDir, 'SKILL.md'), + [ + '---', + 'name: frontmatter-name', + 'description: Mismatch', + '---', + '', + 'Mismatch body', + '', + ].join('\n'), + 'utf8', + ); + + await fsPromises.mkdir(cacheDir, { recursive: true }); + await fsPromises.writeFile( + path.join(cacheDir, 'SKILL.md'), + [ + '---', + 'name: cache-skill', + 'description: Cache skill', + '---', + '', + 'Cache body', + '', + ].join('\n'), + 'utf8', + ); + + expect(() => renameSkill('managed-skill', 'Invalid_Name', projectRoot)).toThrow(/Invalid skill name/); + expect(() => renameSkill('missing-skill', 'new-skill', projectRoot)).toThrow(/not found/); + expect(() => renameSkill('managed-skill', 'taken-name', projectRoot)).toThrow(/already exists/); + expect(() => renameSkill('folder-name', 'renamed-mismatch', projectRoot)).toThrow(/does not match/); + expect(() => renameSkill('cache-skill', 'cache-skill-renamed', projectRoot)).toThrow(/managed skill directories/); + + expect(fs.existsSync(managedDir)).toBe(true); + expect(fs.existsSync(cacheDir)).toBe(true); + expect(fs.existsSync(path.join(projectRoot, '.opencode', 'skills', 'renamed-mismatch'))).toBe(false); + } finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.rm(path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp), { + recursive: true, + force: true, + }); + } + }); });