From f0591515fd2b3af01c05f22aae5417c3b56f65af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 07:02:24 +0000 Subject: [PATCH 1/4] 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 }); + } + }); }); From bfea13ef1d8a67e7bb417d1377513a2d6c093209 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 08:54:29 +0000 Subject: [PATCH 2/4] 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 --- .../sections/skills/SkillsSidebar.tsx | 19 ++- .../sections/skills/skillLocations.ts | 19 +++ .../ui/src/lib/i18n/messages/de.settings.ts | 2 - .../ui/src/lib/i18n/messages/en.settings.ts | 2 - .../ui/src/lib/i18n/messages/es.settings.ts | 2 - .../ui/src/lib/i18n/messages/fr.settings.ts | 2 - .../ui/src/lib/i18n/messages/ja.settings.ts | 2 - .../ui/src/lib/i18n/messages/ko.settings.ts | 2 - .../ui/src/lib/i18n/messages/pl.settings.ts | 2 - .../src/lib/i18n/messages/pt-BR.settings.ts | 2 - .../ui/src/lib/i18n/messages/uk.settings.ts | 2 - .../src/lib/i18n/messages/zh-CN.settings.ts | 2 - .../src/lib/i18n/messages/zh-TW.settings.ts | 2 - packages/vscode/src/opencodeConfig.ts | 62 ++++++++ .../web/server/lib/opencode/DOCUMENTATION.md | 2 +- packages/web/server/lib/opencode/skills.js | 65 ++++++++ .../web/server/lib/opencode/skills.test.js | 143 ++++++++++++++++++ 17 files changed, 303 insertions(+), 29 deletions(-) diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx index 5060624e..98646486 100644 --- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx +++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx @@ -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 = ''; 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 = ({ onItemSelect }) => { const { t } = useI18n(); @@ -140,14 +144,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; } @@ -443,13 +447,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/components/sections/skills/skillLocations.ts b/packages/ui/src/components/sections/skills/skillLocations.ts index a0009a20..cc07fd5e 100644 --- a/packages/ui/src/components/sections/skills/skillLocations.ts +++ b/packages/ui/src/components/sections/skills/skillLocations.ts @@ -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 === '') 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) + ); +} diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 4f9ea716..d35dbb81 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 210f7f3b..fa36d723 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 59b253e5..8ed3af1a 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index aa8ef8e0..afb4cf1b 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 8e2e97de..b820e176 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -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 を削除', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 6bb43de5..80bde6b0 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -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': '스킬 삭제', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 27077db4..eb524ec0 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -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}', 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 55273e8a..d83e4afb 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index a32a8c01..0a64e6e3 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -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": "Видалити навичку", 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 dc2c0b36..f906328d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -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': '删除技能', 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 8d35e718..29563dee 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -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', diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index c46da9eb..1ecce46e 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -2834,6 +2834,57 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void } }; +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 const renameSkill = (oldName: string, newName: string, workingDirectory?: string): void => { ensureSkillDirs(); validateSkillName(newName); @@ -2852,6 +2903,17 @@ export const renameSkill = (oldName: string, newName: string, workingDirectory?: 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) { diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 02088195..1f21f955 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -351,7 +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) + - 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`) - Skills catalog listing/source pagination, scan, and install routes - Supporting skill file read/write/delete routes diff --git a/packages/web/server/lib/opencode/skills.js b/packages/web/server/lib/opencode/skills.js index 5f1a53d2..a0594ad3 100644 --- a/packages/web/server/lib/opencode/skills.js +++ b/packages/web/server/lib/opencode/skills.js @@ -602,6 +602,60 @@ 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); @@ -620,6 +674,17 @@ function renameSkill(oldName, newName, workingDirectory) { 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) { diff --git a/packages/web/server/lib/opencode/skills.test.js b/packages/web/server/lib/opencode/skills.test.js index bda9ea97..6fec177e 100644 --- a/packages/web/server/lib/opencode/skills.test.js +++ b/packages/web/server/lib/opencode/skills.test.js @@ -178,4 +178,147 @@ describe('skills', () => { 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 unmanagedDir = path.join(projectRoot, 'custom-skills', 'unmanaged-skill'); + 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(unmanagedDir, { recursive: true }); + await fsPromises.writeFile( + path.join(unmanagedDir, 'SKILL.md'), + [ + '---', + 'name: unmanaged-skill', + 'description: Unmanaged', + '---', + '', + 'Unmanaged 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, + }); + } + }); }); From 20fc675af0e0fb445933a908be531adaa823fb74 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 09:25:25 +0000 Subject: [PATCH 3/4] fix(skills): drive UI rename gating from server renamable flag Expose authoritative renamable on skill list responses using the same managed-root policy as renameSkill, drop the divergent UI path heuristic, and remove an unused rejection-test fixture. Co-authored-by: Serhii Dziupin --- .../sections/skills/SkillsSidebar.tsx | 3 +-- .../sections/skills/skillLocations.ts | 19 ------------------- packages/ui/src/stores/useSkillsStore.ts | 4 ++++ packages/vscode/src/bridge-config-runtime.ts | 17 ++++++++++++++++- packages/vscode/src/opencodeConfig.ts | 2 ++ .../web/server/lib/opencode/DOCUMENTATION.md | 1 + .../lib/opencode/feature-routes-runtime.js | 3 ++- .../web/server/lib/opencode/skill-routes.js | 9 ++++++++- packages/web/server/lib/opencode/skills.js | 1 + .../web/server/lib/opencode/skills.test.js | 16 ---------------- 10 files changed, 35 insertions(+), 40 deletions(-) diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx index 98646486..f440b9a5 100644 --- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx +++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx @@ -27,7 +27,6 @@ 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; @@ -37,7 +36,7 @@ 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) && isManagedSkillFilesystemPath(skill.path) + !!skill && !isBuiltInSkill(skill) && skill.renamable === true ); export const SkillsSidebar: React.FC = ({ onItemSelect }) => { diff --git a/packages/ui/src/components/sections/skills/skillLocations.ts b/packages/ui/src/components/sections/skills/skillLocations.ts index cc07fd5e..a0009a20 100644 --- a/packages/ui/src/components/sections/skills/skillLocations.ts +++ b/packages/ui/src/components/sections/skills/skillLocations.ts @@ -57,22 +57,3 @@ 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 === '') 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) - ); -} diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index 9acbde46..76f7b9c4 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -70,6 +70,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. @@ -93,6 +95,7 @@ interface RawSkillResponse { path: string; scope?: SkillScope; source?: SkillSource; + renamable?: boolean; sources?: { md?: { description?: string; @@ -237,6 +240,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 }); diff --git a/packages/vscode/src/bridge-config-runtime.ts b/packages/vscode/src/bridge-config-runtime.ts index 569900e5..0ec36bee 100644 --- a/packages/vscode/src/bridge-config-runtime.ts +++ b/packages/vscode/src/bridge-config-runtime.ts @@ -26,6 +26,7 @@ import { updateSkill, deleteSkill, renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -653,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() : ''; diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 1ecce46e..0bf94c32 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -2885,6 +2885,8 @@ const isManagedSkillPath = (skillMdPath: string, workingDirectory?: string): boo return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root)); }; +export { isManagedSkillPath }; + export const renameSkill = (oldName: string, newName: string, workingDirectory?: string): void => { ensureSkillDirs(); validateSkillName(newName); diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 1f21f955..9e4a6f3b 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -352,6 +352,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`. - `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 diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index dd3726d7..69efc12b 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, renameSkill } 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,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { 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 314e6b99..db6893ce 100644 --- a/packages/web/server/lib/opencode/skill-routes.js +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -22,6 +22,7 @@ export const registerSkillRoutes = (app, dependencies) => { updateSkill, deleteSkill, renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -213,9 +214,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) + ), }; }); diff --git a/packages/web/server/lib/opencode/skills.js b/packages/web/server/lib/opencode/skills.js index a0594ad3..9ee24d6b 100644 --- a/packages/web/server/lib/opencode/skills.js +++ b/packages/web/server/lib/opencode/skills.js @@ -734,4 +734,5 @@ export { 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 6fec177e..d19bf3d3 100644 --- a/packages/web/server/lib/opencode/skills.test.js +++ b/packages/web/server/lib/opencode/skills.test.js @@ -224,7 +224,6 @@ describe('skills', () => { 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 unmanagedDir = path.join(projectRoot, 'custom-skills', 'unmanaged-skill'); const cacheStamp = `oc-rename-${Date.now()}`; const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-skill'); @@ -274,21 +273,6 @@ describe('skills', () => { 'utf8', ); - await fsPromises.mkdir(unmanagedDir, { recursive: true }); - await fsPromises.writeFile( - path.join(unmanagedDir, 'SKILL.md'), - [ - '---', - 'name: unmanaged-skill', - 'description: Unmanaged', - '---', - '', - 'Unmanaged body', - '', - ].join('\n'), - 'utf8', - ); - await fsPromises.mkdir(cacheDir, { recursive: true }); await fsPromises.writeFile( path.join(cacheDir, 'SKILL.md'), From 0d24d0a167cdd9b10be98a79240be4f54054c796 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 11:05:27 +0000 Subject: [PATCH 4/4] fix(skills): repair renameSkill directory resolution after merge Use getRequestDirectory and x-opencode-directory like the other skill mutations, and pin renamable list/store mapping with focused tests. Co-authored-by: Serhii Dziupin --- packages/ui/src/stores/useSkillsStore.test.ts | 85 +++++++++++++++++++ packages/ui/src/stores/useSkillsStore.ts | 11 ++- .../server/lib/opencode/skill-routes.test.js | 62 ++++++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) 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 2614a8df..2157db21 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -408,12 +408,15 @@ export const useSkillsStore = create()( startConfigUpdate("Renaming skill..."); let requiresReload = false; try { - const currentDirectory = getCurrentDirectory(); - const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; + 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' }, + headers: { + 'Content-Type': 'application/json', + ...(directory ? { 'x-opencode-directory': directory } : {}), + }, body: JSON.stringify({ renameTo: newName }), }); @@ -424,7 +427,7 @@ export const useSkillsStore = create()( } const needsReload = payload?.requiresReload ?? false; - invalidateSkillsLoadCache(currentDirectory); + invalidateSkillsLoadCache(directory); if (needsReload) { requiresReload = true; await refreshSkillsAfterOpenCodeRestart({ 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, + }); + } + }); });