From f0591515fd2b3af01c05f22aae5417c3b56f65af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 07:02:24 +0000 Subject: [PATCH] fix(skills): preserve SKILL.md content when renaming Rename skills by moving the skill directory and updating frontmatter name instead of recreate-with-stub-description, which wiped the body and supporting files. Co-authored-by: Serhii Dziupin --- .../sections/skills/SkillsSidebar.tsx | 32 ++------ .../ui/src/lib/i18n/messages/de.settings.ts | 1 + .../ui/src/lib/i18n/messages/en.settings.ts | 1 + .../ui/src/lib/i18n/messages/es.settings.ts | 1 + .../ui/src/lib/i18n/messages/fr.settings.ts | 1 + .../ui/src/lib/i18n/messages/ja.settings.ts | 1 + .../ui/src/lib/i18n/messages/ko.settings.ts | 1 + .../ui/src/lib/i18n/messages/pl.settings.ts | 1 + .../src/lib/i18n/messages/pt-BR.settings.ts | 1 + .../ui/src/lib/i18n/messages/uk.settings.ts | 1 + .../src/lib/i18n/messages/zh-CN.settings.ts | 1 + .../src/lib/i18n/messages/zh-TW.settings.ts | 1 + packages/ui/src/stores/useSkillsStore.ts | 45 +++++++++++ packages/vscode/src/bridge-config-runtime.ts | 19 +++++ packages/vscode/src/opencodeConfig.ts | 58 +++++++++++++- .../web/server/lib/opencode/DOCUMENTATION.md | 1 + .../lib/opencode/feature-routes-runtime.js | 3 +- .../web/server/lib/opencode/skill-routes.js | 17 ++++ packages/web/server/lib/opencode/skills.js | 78 ++++++++++++++++++- .../web/server/lib/opencode/skills.test.js | 70 ++++++++++++++++- 20 files changed, 301 insertions(+), 33 deletions(-) diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx index 0b13c6e0..5060624e 100644 --- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx +++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx @@ -49,16 +49,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, }))); @@ -169,31 +169,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')); } diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 8bc4fe64..4f9ea716 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -669,6 +669,7 @@ export const settingsDict = { '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 ece90e3f..210f7f3b 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -721,6 +721,7 @@ export const settingsDict = { '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 657a936c..59b253e5 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -688,6 +688,7 @@ export const settingsDict = { "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 050f7904..aa8ef8e0 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -609,6 +609,7 @@ export const settingsDict = { '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 be7fe2b3..8e2e97de 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -721,6 +721,7 @@ export const settingsDict = { '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 accc0d40..6bb43de5 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -688,6 +688,7 @@ export const settingsDict = { '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 318072d8..27077db4 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1867,6 +1867,7 @@ export const settingsDict = { '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 a931c456..55273e8a 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -688,6 +688,7 @@ export const settingsDict = { "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 65655407..a32a8c01 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -688,6 +688,7 @@ export const settingsDict = { "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 3650a25a..dc2c0b36 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -688,6 +688,7 @@ export const settingsDict = { '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 c027b3ad..8d35e718 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -685,6 +685,7 @@ '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.ts b/packages/ui/src/stores/useSkillsStore.ts index 2ffe69dd..9acbde46 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -143,6 +143,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; @@ -382,6 +383,50 @@ export const useSkillsStore = create()( } }, + renameSkill: async (name: string, newName: string) => { + startConfigUpdate("Renaming skill..."); + let requiresReload = false; + try { + const currentDirectory = getCurrentDirectory(); + const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; + + const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + 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(currentDirectory); + 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..569900e5 100644 --- a/packages/vscode/src/bridge-config-runtime.ts +++ b/packages/vscode/src/bridge-config-runtime.ts @@ -25,6 +25,7 @@ import { createSkill, updateSkill, deleteSkill, + renameSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -693,6 +694,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 79a7455d..c46da9eb 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -2761,7 +2761,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); @@ -2833,3 +2833,59 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void throw new Error(`Skill "${skillName}" not found`); } }; + +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`); + } + + 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 c57ecb8b..02088195 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -351,6 +351,7 @@ 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) - Skills catalog listing/source pagination, scan, and install routes - Supporting skill file read/write/delete routes diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index d127515d..dd3726d7 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 } 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'; @@ -256,6 +256,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { createSkill, updateSkill, deleteSkill, + renameSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, diff --git a/packages/web/server/lib/opencode/skill-routes.js b/packages/web/server/lib/opencode/skill-routes.js index b66680a5..314e6b99 100644 --- a/packages/web/server/lib/opencode/skill-routes.js +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -21,6 +21,7 @@ export const registerSkillRoutes = (app, dependencies) => { createSkill, updateSkill, deleteSkill, + renameSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -611,6 +612,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/skills.js b/packages/web/server/lib/opencode/skills.js index 91027b52..5f1a53d2 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,65 @@ function deleteSkill(skillName, workingDirectory) { } } +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`); + } + + 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 +668,5 @@ export { createSkill, updateSkill, deleteSkill, + renameSkill, }; diff --git a/packages/web/server/lib/opencode/skills.test.js b/packages/web/server/lib/opencode/skills.test.js index 95f9c722..bda9ea97 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 { getSkillSources, mergeDiscoveredSkills } from './skills.js'; +import { getSkillSources, mergeDiscoveredSkills, renameSkill } from './skills.js'; describe('skills', () => { it('merges locally discovered skills missing from OpenCode live discovery', () => { @@ -110,4 +111,71 @@ 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 }); + } + }); });