Merge pull request #2586 from openchamber/feat/skill-renaming-content-preservation-c1d5

fix(skills): preserve SKILL.md content when renaming
This commit is contained in:
Serhii Dziupin
2026-08-03 15:04:48 +03:00
committed by GitHub
22 changed files with 750 additions and 63 deletions
@@ -35,6 +35,9 @@ interface SkillsSidebarProps {
const BUILT_IN_SKILL_LOCATION = '<built-in>';
const isBuiltInSkill = (skill: DiscoveredSkill | null | undefined): boolean => skill?.path === BUILT_IN_SKILL_LOCATION;
const isRenamableSkill = (skill: DiscoveredSkill | null | undefined): boolean => (
!!skill && !isBuiltInSkill(skill) && skill.renamable === true
);
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
@@ -49,16 +52,16 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
skills,
setSelectedSkill,
setSkillDraft,
createSkill,
deleteSkill,
renameSkill,
getSkillDetail,
} = useSkillsStore(useShallow((s) => ({
selectedSkillName: s.selectedSkillName,
skills: s.skills,
setSelectedSkill: s.setSelectedSkill,
setSkillDraft: s.setSkillDraft,
createSkill: s.createSkill,
deleteSkill: s.deleteSkill,
renameSkill: s.renameSkill,
getSkillDetail: s.getSkillDetail,
})));
@@ -140,14 +143,14 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
};
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
if (isBuiltInSkill(skill)) return;
if (!isRenamableSkill(skill)) return;
setRenameNewName(skill.name);
setRenameDialogSkill(skill);
};
const handleRenameSkill = async () => {
if (!renameDialogSkill) return;
if (isBuiltInSkill(renameDialogSkill)) {
if (!isRenamableSkill(renameDialogSkill)) {
setRenameDialogSkill(null);
return;
}
@@ -169,31 +172,11 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
return;
}
// Get full detail to copy
const detail = await getSkillDetail(renameDialogSkill.name);
if (!detail) {
toast.error(t('settings.skills.sidebar.toast.renameLoadFailed'));
setRenameDialogSkill(null);
return;
}
// Create new skill with new name
const success = await createSkill({
name: sanitizedName,
description: 'Renamed skill', // Will need proper description
scope: renameDialogSkill.scope,
source: renameDialogSkill.source,
});
// Rename in place on disk so SKILL.md body and supporting files are preserved.
const success = await renameSkill(renameDialogSkill.name, sanitizedName);
if (success) {
// Delete old skill
const deleteSuccess = await deleteSkill(renameDialogSkill.name);
if (deleteSuccess) {
toast.success(`Skill renamed to "${sanitizedName}"`);
setSelectedSkill(sanitizedName);
} else {
toast.error(t('settings.skills.sidebar.toast.removeOldAfterRenameFailed'));
}
toast.success(t('settings.skills.sidebar.toast.skillRenamed', { name: sanitizedName }));
setSelectedSkill(sanitizedName);
} else {
toast.error(t('settings.skills.sidebar.toast.renameFailed'));
}
@@ -463,13 +446,16 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
: t('settings.skills.sidebar.badge.opencode');
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
const isBuiltIn = isBuiltInSkill(skill);
const canRename = isRenamableSkill(skill);
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const renderMenuItems = (Item: React.ElementType) => (
<>
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</Item>
{canRename ? (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</Item>
) : null}
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDuplicate(); }}>
<Icon name="file-copy" className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
@@ -666,9 +666,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" erfolgreich gelöscht',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Skill konnte nicht gelöscht werden',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Skill-Details für Duplizierung konnten nicht geladen werden',
'settings.skills.sidebar.toast.renameLoadFailed': 'Skill-Details konnten nicht geladen werden',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Alter Skill konnte nach Umbenennung nicht entfernt werden',
'settings.skills.sidebar.toast.renameFailed': 'Skill konnte nicht umbenannt werden',
'settings.skills.sidebar.toast.skillRenamed': 'Skill umbenannt in "{name}"',
'settings.skills.sidebar.deleteDialog.title': 'Skill löschen',
'settings.skills.sidebar.deleteDialog.description': 'Möchten Sie den Skill "{name}" wirklich löschen?',
'settings.skills.sidebar.renameDialog.title': 'Skill umbenennen',
@@ -718,9 +718,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" deleted successfully',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Failed to delete skill',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Failed to load skill details for duplication',
'settings.skills.sidebar.toast.renameLoadFailed': 'Failed to load skill details',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Failed to remove old skill after rename',
'settings.skills.sidebar.toast.renameFailed': 'Failed to rename skill',
'settings.skills.sidebar.toast.skillRenamed': 'Skill renamed to "{name}"',
'settings.skills.sidebar.deleteDialog.title': 'Delete Skill',
'settings.skills.sidebar.deleteDialog.description': 'Are you sure you want to delete skill "{name}"?',
'settings.skills.sidebar.renameDialog.title': 'Rename Skill',
@@ -685,9 +685,8 @@ export const settingsDict = {
"settings.skills.sidebar.toast.skillDeleted": "Habilidad \"{name}\" eliminada con éxito",
"settings.skills.sidebar.toast.deleteSkillFailed": "No se pudo eliminar la habilidad",
"settings.skills.sidebar.toast.duplicateLoadFailed": "No se pudo cargar la información de la habilidad para duplicarla",
"settings.skills.sidebar.toast.renameLoadFailed": "No se pudo cargar la información de la habilidad",
"settings.skills.sidebar.toast.removeOldAfterRenameFailed": "No se pudo eliminar la habilidad antigua después del cambio de nombre",
"settings.skills.sidebar.toast.renameFailed": "No se pudo cambiar el nombre de la habilidad",
"settings.skills.sidebar.toast.skillRenamed": "Habilidad renombrada a \"{name}\"",
"settings.skills.sidebar.deleteDialog.title": "Eliminar habilidad",
"settings.skills.sidebar.deleteDialog.description": "¿Estás seguro de que quieres eliminar la habilidad \"{name}\"?",
"settings.skills.sidebar.renameDialog.title": "Cambiar nombre habilidad",
@@ -606,9 +606,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" supprimé avec succès',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Échec de la suppression du skill',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Échec du chargement des détails du skill pour la duplication',
'settings.skills.sidebar.toast.renameLoadFailed': 'Échec du chargement des détails du skill',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Échec de la suppression de l\'ancien skill après le renommage',
'settings.skills.sidebar.toast.renameFailed': 'Échec du renommage du skill',
'settings.skills.sidebar.toast.skillRenamed': 'Skill renommé en "{name}"',
'settings.skills.sidebar.deleteDialog.title': 'Supprimer le skill',
'settings.skills.sidebar.deleteDialog.description': 'Êtes-vous sûr de vouloir supprimer le skill « {name} » ?',
'settings.skills.sidebar.renameDialog.title': 'Renommer le skill',
@@ -718,9 +718,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" を削除しました',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Skill の削除に失敗しました',
'settings.skills.sidebar.toast.duplicateLoadFailed': '複製用の Skill 詳細の読み込みに失敗しました',
'settings.skills.sidebar.toast.renameLoadFailed': 'Skill 詳細の読み込みに失敗しました',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '名前変更後に古い Skill の削除に失敗しました',
'settings.skills.sidebar.toast.renameFailed': 'Skill の名前変更に失敗しました',
'settings.skills.sidebar.toast.skillRenamed': 'Skill の名前を「{name}」に変更しました',
'settings.skills.sidebar.deleteDialog.title': 'Skill を削除',
'settings.skills.sidebar.deleteDialog.description': 'Skill "{name}" を削除してもよろしいですか?',
'settings.skills.sidebar.renameDialog.title': 'Skill の名前変更',
@@ -685,9 +685,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': '스킬 "{name}"을 삭제했습니다',
'settings.skills.sidebar.toast.deleteSkillFailed': '스킬을 삭제하지 못했습니다',
'settings.skills.sidebar.toast.duplicateLoadFailed': '복제를 위한 스킬 세부 정보를 로드하지 못했습니다',
'settings.skills.sidebar.toast.renameLoadFailed': '스킬 세부 정보를 로드하지 못했습니다',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '이름 변경 후 이전 스킬을 제거하지 못했습니다',
'settings.skills.sidebar.toast.renameFailed': '스킬 이름을 변경하지 못했습니다',
'settings.skills.sidebar.toast.skillRenamed': '스킬 이름이 "{name}"(으)로 변경되었습니다',
'settings.skills.sidebar.deleteDialog.title': '스킬 삭제',
'settings.skills.sidebar.deleteDialog.description': '스킬 "{name}"을 삭제하시겠습니까?',
'settings.skills.sidebar.renameDialog.title': '스킬 이름 변경',
@@ -1922,10 +1922,9 @@ export const settingsDict = {
'settings.skills.sidebar.title': 'Umiejętności',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Nie udało się usunąć umiejętności',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Nie udało się załadować szczegółów umiejętności do duplikacji',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Nie udało się usunąć starej umiejętności po zmianie nazwy',
'settings.skills.sidebar.toast.renameFailed': 'Nie udało się zmienić nazwy umiejętności',
'settings.skills.sidebar.toast.renameLoadFailed': 'Nie udało się załadować szczegółów umiejętności',
'settings.skills.sidebar.toast.skillDeleted': 'Umiejętność „{name}” została usunięta',
'settings.skills.sidebar.toast.skillRenamed': 'Zmieniono nazwę umiejętności na „{name}”',
'settings.skills.sidebar.total': 'Suma: {count}',
'settings.usage.pace.prediction': 'Prognoza: {prediction}',
'settings.usage.pace.predictionLabel': 'Prognoza: ',
@@ -685,9 +685,8 @@ export const settingsDict = {
"settings.skills.sidebar.toast.skillDeleted": "Habilidade \"{name}\" excluída com sucesso",
"settings.skills.sidebar.toast.deleteSkillFailed": "Não foi possível excluir a habilidade",
"settings.skills.sidebar.toast.duplicateLoadFailed": "Não foi possível carregar as informações da habilidade para duplicá-la",
"settings.skills.sidebar.toast.renameLoadFailed": "Não foi possível carregar as informações da habilidade",
"settings.skills.sidebar.toast.removeOldAfterRenameFailed": "Não foi possível excluir a habilidade antiga depois da renomeação",
"settings.skills.sidebar.toast.renameFailed": "Não foi possível renomear da habilidade",
"settings.skills.sidebar.toast.skillRenamed": "Habilidade renomeada para \"{name}\"",
"settings.skills.sidebar.deleteDialog.title": "Excluir habilidade",
"settings.skills.sidebar.deleteDialog.description": "Tem certeza de que deseja excluir a habilidade \"{name}\"?",
"settings.skills.sidebar.renameDialog.title": "Renomear habilidade",
@@ -685,9 +685,8 @@ export const settingsDict = {
"settings.skills.sidebar.toast.skillDeleted": "Навичку \"{name}\" успішно видалено",
"settings.skills.sidebar.toast.deleteSkillFailed": "Не вдалося видалити навичку",
"settings.skills.sidebar.toast.duplicateLoadFailed": "Не вдалося завантажити деталі навичок для дублювання",
"settings.skills.sidebar.toast.renameLoadFailed": "Не вдалося завантажити деталі навичок",
"settings.skills.sidebar.toast.removeOldAfterRenameFailed": "Не вдалося видалити стару навичку після перейменування",
"settings.skills.sidebar.toast.renameFailed": "Не вдалося перейменувати навичку",
"settings.skills.sidebar.toast.skillRenamed": "Навичку перейменовано на \"{name}\"",
"settings.skills.sidebar.deleteDialog.title": "Видалити навичку",
"settings.skills.sidebar.deleteDialog.description": "Ви впевнені, що бажаєте видалити навичку «{name}»?",
"settings.skills.sidebar.renameDialog.title": "Перейменувати навичку",
@@ -685,9 +685,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': '技能“{name}”已删除',
'settings.skills.sidebar.toast.deleteSkillFailed': '删除技能失败',
'settings.skills.sidebar.toast.duplicateLoadFailed': '加载技能详情以复制失败',
'settings.skills.sidebar.toast.renameLoadFailed': '加载技能详情失败',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '重命名后移除旧技能失败',
'settings.skills.sidebar.toast.renameFailed': '重命名技能失败',
'settings.skills.sidebar.toast.skillRenamed': '技能已重命名为“{name}”',
'settings.skills.sidebar.deleteDialog.title': '删除技能',
'settings.skills.sidebar.deleteDialog.description': '确定要删除技能“{name}”吗?',
'settings.skills.sidebar.renameDialog.title': '重命名技能',
@@ -682,9 +682,8 @@
'settings.skills.sidebar.toast.skillDeleted': 'skill「{name}」已刪除',
'settings.skills.sidebar.toast.deleteSkillFailed': '刪除 skill 失敗',
'settings.skills.sidebar.toast.duplicateLoadFailed': '複製 skill 的詳細資訊載入失敗',
'settings.skills.sidebar.toast.renameLoadFailed': '載入 skill 詳情失敗',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '重新命名後移除舊 skill 失敗',
'settings.skills.sidebar.toast.renameFailed': '重新命名 skill 失敗',
'settings.skills.sidebar.toast.skillRenamed': 'skill 已重新命名為「{name}」',
'settings.skills.sidebar.deleteDialog.title': '刪除 Skill',
'settings.skills.sidebar.deleteDialog.description': '確定要刪除 skill「{name}」嗎?',
'settings.skills.sidebar.renameDialog.title': '重新命名 Skill',
@@ -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);
+52
View File
@@ -76,6 +76,8 @@ export interface DiscoveredSkill {
description?: string;
/** Domain folder parsed from file path, e.g. "automation-ai", "lark-ecosystem" */
group?: string;
/** Authoritative server flag: skill lives under a managed root and can be renamed in place. */
renamable?: boolean;
}
/** Parse the domain group folder from a skill file path.
@@ -99,6 +101,7 @@ interface RawSkillResponse {
path: string;
scope?: SkillScope;
source?: SkillSource;
renamable?: boolean;
sources?: {
md?: {
description?: string;
@@ -149,6 +152,7 @@ interface SkillsStore {
getSkillDetail: (name: string) => Promise<SkillDetail | null>;
createSkill: (config: SkillConfig) => Promise<boolean>;
updateSkill: (name: string, config: Partial<SkillConfig>) => Promise<boolean>;
renameSkill: (name: string, newName: string) => Promise<boolean>;
deleteSkill: (name: string) => Promise<boolean>;
getSkillByName: (name: string) => DiscoveredSkill | undefined;
@@ -245,6 +249,7 @@ export const useSkillsStore = create<SkillsStore>()(
source: s.source ?? 'opencode',
description: s.sources?.md?.description || '',
group: parseSkillGroup(s.path),
renamable: s.renamable === true,
}));
set({ skills: configSkills, isLoading: false });
@@ -399,6 +404,53 @@ export const useSkillsStore = create<SkillsStore>()(
}
},
renameSkill: async (name: string, newName: string) => {
startConfigUpdate("Renaming skill...");
let requiresReload = false;
try {
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify({ renameTo: newName }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to rename skill';
throw new Error(message);
}
const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(directory);
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
deleteSkill: async (name: string) => {
startConfigUpdate("Deleting skill...");
let requiresReload = false;
+35 -1
View File
@@ -25,6 +25,8 @@ import {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
@@ -652,7 +654,21 @@ export async function handleConfigBridgeMessage(
if (!name && normalizedMethod === 'GET') {
const skills = await resolveDiscoveredSkills(deps, ctx, workingDirectory);
return { id, type, success: true, data: { skills } };
return {
id,
type,
success: true,
data: {
skills: skills.map((skill) => ({
...skill,
renamable: Boolean(
skill.path
&& skill.path !== '<built-in>'
&& isManagedSkillPath(skill.path, workingDirectory)
),
})),
},
};
}
const skillName = typeof name === 'string' ? name.trim() : '';
@@ -693,6 +709,24 @@ export async function handleConfigBridgeMessage(
}
if (normalizedMethod === 'PATCH') {
if (typeof body?.renameTo === 'string') {
const newName = body.renameTo.trim();
renameSkill(skillName, newName, workingDirectory);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
name: newName,
requiresReload: true,
message: `Skill renamed to ${newName} successfully. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
await ctx?.manager?.restart();
return {
+121 -1
View File
@@ -2918,7 +2918,7 @@ export const updateSkill = (skillName: string, updates: Record<string, unknown>,
let mdModified = false;
for (const [field, value] of Object.entries(updates || {})) {
if (field === 'scope') continue;
if (field === 'scope' || field === 'source' || field === 'targetPath' || field === 'renameTo') continue;
if (field === 'instructions') {
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
@@ -2990,3 +2990,123 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void
throw new Error(`Skill "${skillName}" not found`);
}
};
const isPathInside = (candidatePath: string, parentPath: string): boolean => {
const resolvedCandidate = path.resolve(candidatePath);
const resolvedParent = path.resolve(parentPath);
return resolvedCandidate === resolvedParent
|| resolvedCandidate.startsWith(`${resolvedParent}${path.sep}`);
};
const getManagedSkillRoots = (workingDirectory?: string): string[] => {
const roots: string[] = [];
const pushRoot = (dir?: string | null) => {
if (!dir) return;
const resolved = path.resolve(dir);
if (!roots.includes(resolved)) {
roots.push(resolved);
}
};
pushRoot(SKILL_DIR);
pushRoot(path.join(OPENCODE_CONFIG_DIR, 'skill'));
pushRoot(path.join(os.homedir(), '.opencode', 'skills'));
pushRoot(path.join(os.homedir(), '.opencode', 'skill'));
pushRoot(path.join(os.homedir(), '.claude', 'skills'));
pushRoot(path.join(os.homedir(), '.agents', 'skills'));
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
: null;
pushRoot(customConfigDir ? path.join(customConfigDir, 'skills') : null);
pushRoot(customConfigDir ? path.join(customConfigDir, 'skill') : null);
if (workingDirectory) {
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
for (const ancestor of getAncestors(workingDirectory, worktreeRoot)) {
pushRoot(path.join(ancestor, '.opencode', 'skills'));
pushRoot(path.join(ancestor, '.opencode', 'skill'));
pushRoot(path.join(ancestor, '.claude', 'skills'));
pushRoot(path.join(ancestor, '.agents', 'skills'));
}
}
return roots;
};
const isManagedSkillPath = (skillMdPath: string, workingDirectory?: string): boolean => {
if (!skillMdPath || skillMdPath === BUILT_IN_SKILL_LOCATION) {
return false;
}
const skillDir = path.dirname(path.resolve(skillMdPath));
return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root));
};
export { isManagedSkillPath };
export const renameSkill = (oldName: string, newName: string, workingDirectory?: string): void => {
ensureSkillDirs();
validateSkillName(newName);
if (oldName === newName) {
return;
}
const existing = getSkillScope(oldName, workingDirectory);
if (!existing.path) {
throw new Error(`Skill "${oldName}" not found`);
}
if (existing.path === BUILT_IN_SKILL_LOCATION || !fs.existsSync(existing.path)) {
throw new Error(`Skill "${oldName}" cannot be renamed`);
}
if (path.basename(existing.path) !== 'SKILL.md') {
throw new Error(`Skill "${oldName}" target must be a SKILL.md file`);
}
if (!isManagedSkillPath(existing.path, workingDirectory)) {
throw new Error(`Skill "${oldName}" is outside managed skill directories and cannot be renamed`);
}
const mdDataBeforeMove = parseMdFile(existing.path);
const frontmatterName = typeof mdDataBeforeMove.frontmatter?.name === 'string'
? mdDataBeforeMove.frontmatter.name
: oldName;
if (frontmatterName !== oldName) {
throw new Error(`Skill "${oldName}" does not match ${existing.path}`);
}
const conflict = getSkillScope(newName, workingDirectory);
if (conflict.path) {
throw new Error(`Skill ${newName} already exists at ${conflict.path}`);
}
const oldDir = path.dirname(existing.path);
const newDir = path.join(path.dirname(oldDir), newName);
const directoriesDiffer = path.resolve(oldDir) !== path.resolve(newDir);
if (directoriesDiffer && fs.existsSync(newDir)) {
throw new Error(`Skill directory already exists at ${newDir}`);
}
if (directoriesDiffer) {
fs.renameSync(oldDir, newDir);
}
const newPath = path.join(newDir, 'SKILL.md');
try {
const mdData = parseMdFile(newPath);
mdData.frontmatter = {
...mdData.frontmatter,
name: newName,
};
writeMdFile(newPath, mdData.frontmatter, mdData.body);
} catch (error) {
if (directoriesDiffer && fs.existsSync(newDir) && !fs.existsSync(oldDir)) {
try {
fs.renameSync(newDir, oldDir);
} catch {
// Best-effort rollback; surface the original write failure.
}
}
throw error;
}
};
@@ -358,6 +358,8 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
## Public exports (skill-routes.js)
- `registerSkillRoutes(app, dependencies)`: registers skills-related routes:
- Skills config CRUD and metadata under `/api/config/skills*`
- Skill rename via `PATCH /api/config/skills/:name` with `{ renameTo }` (directory rename preserves `SKILL.md` body and supporting files; restricted to managed skill roots under `.opencode/skills|skill`, `.claude/skills`, and `.agents/skills`)
- Skill list responses include authoritative `renamable` derived from the same managed-root policy used by rename
- Skills catalog listing/source pagination, scan, and install routes
- Supporting skill file read/write/delete routes
- Directory resolution prefers an explicit request directory, then soft-falls
@@ -38,7 +38,7 @@ import {
decodePluginId,
} from './plugins.js';
import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js';
import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill } from './skills.js';
import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill, renameSkill, isManagedSkillPath } from './skills.js';
import { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js';
import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js';
import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js';
@@ -257,6 +257,8 @@ export const createFeatureRoutesRuntime = (dependencies) => {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
@@ -21,6 +21,8 @@ export const registerSkillRoutes = (app, dependencies) => {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
@@ -236,9 +238,15 @@ export const registerSkillRoutes = (app, dependencies) => {
const enrichedSkills = skills.map((skill) => {
const sources = getSkillSources(skill.name, directory, skill);
const skillPath = typeof skill.path === 'string' ? skill.path : null;
return {
...skill,
sources
sources,
renamable: Boolean(
skillPath
&& skillPath !== '<built-in>'
&& isManagedSkillPath(skillPath, directory)
),
};
});
@@ -635,6 +643,22 @@ export const registerSkillRoutes = (app, dependencies) => {
return res.status(400).json({ error });
}
if (typeof updates?.renameTo === 'string') {
const newName = updates.renameTo.trim();
console.log(`[Server] Renaming skill: ${skillName} -> ${newName}`);
console.log('[Server] Working directory:', directory);
renameSkill(skillName, newName, directory);
await refreshOpenCodeAfterConfigChange('skill rename');
return res.json({
success: true,
name: newName,
requiresReload: true,
message: `Skill renamed to ${newName} successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
}
console.log(`[Server] Updating skill: ${skillName}`);
console.log('[Server] Working directory:', directory);
@@ -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,
});
}
});
});
+140 -4
View File
@@ -412,12 +412,22 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
return sources;
}
function createSkill(skillName, config, workingDirectory, scope) {
ensureDirs();
function isValidSkillName(skillName) {
return typeof skillName === 'string'
&& skillName.length > 0
&& skillName.length <= 64
&& /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName);
}
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
function assertValidSkillName(skillName) {
if (!isValidSkillName(skillName)) {
throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`);
}
}
function createSkill(skillName, config, workingDirectory, scope) {
ensureDirs();
assertValidSkillName(skillName);
const existing = getSkillScope(skillName, workingDirectory);
if (existing.path) {
@@ -505,7 +515,7 @@ function updateSkill(skillName, updates, workingDirectory, targetPath = null) {
let mdModified = false;
for (const [field, value] of Object.entries(updates)) {
if (field === 'scope' || field === 'source' || field === 'targetPath') {
if (field === 'scope' || field === 'source' || field === 'targetPath' || field === 'renameTo') {
continue;
}
@@ -592,6 +602,130 @@ function deleteSkill(skillName, workingDirectory) {
}
}
function isPathInside(candidatePath, parentPath) {
if (!candidatePath || !parentPath) return false;
const resolvedCandidate = path.resolve(candidatePath);
const resolvedParent = path.resolve(parentPath);
return resolvedCandidate === resolvedParent
|| resolvedCandidate.startsWith(`${resolvedParent}${path.sep}`);
}
function getManagedSkillRoots(workingDirectory) {
const roots = [];
const pushRoot = (dir) => {
if (!dir) return;
const resolved = path.resolve(dir);
if (!roots.includes(resolved)) {
roots.push(resolved);
}
};
pushRoot(SKILL_DIR);
pushRoot(path.join(OPENCODE_CONFIG_DIR, 'skill'));
pushRoot(path.join(os.homedir(), '.opencode', 'skills'));
pushRoot(path.join(os.homedir(), '.opencode', 'skill'));
pushRoot(path.join(os.homedir(), '.claude', 'skills'));
pushRoot(path.join(os.homedir(), '.agents', 'skills'));
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
: null;
if (customConfigDir) {
pushRoot(path.join(customConfigDir, 'skills'));
pushRoot(path.join(customConfigDir, 'skill'));
}
if (workingDirectory) {
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
for (const ancestor of getAncestors(workingDirectory, worktreeRoot)) {
pushRoot(path.join(ancestor, '.opencode', 'skills'));
pushRoot(path.join(ancestor, '.opencode', 'skill'));
pushRoot(path.join(ancestor, '.claude', 'skills'));
pushRoot(path.join(ancestor, '.agents', 'skills'));
}
}
return roots;
}
function isManagedSkillPath(skillMdPath, workingDirectory) {
if (!skillMdPath || skillMdPath === BUILT_IN_SKILL_LOCATION) {
return false;
}
const skillDir = path.dirname(path.resolve(skillMdPath));
return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root));
}
function renameSkill(oldName, newName, workingDirectory) {
ensureDirs();
assertValidSkillName(newName);
if (oldName === newName) {
return;
}
const existing = getSkillScope(oldName, workingDirectory);
if (!existing.path) {
throw new Error(`Skill "${oldName}" not found`);
}
if (existing.path === BUILT_IN_SKILL_LOCATION || !fs.existsSync(existing.path)) {
throw new Error(`Skill "${oldName}" cannot be renamed`);
}
if (path.basename(existing.path) !== 'SKILL.md') {
throw new Error(`Skill "${oldName}" target must be a SKILL.md file`);
}
if (!isManagedSkillPath(existing.path, workingDirectory)) {
throw new Error(`Skill "${oldName}" is outside managed skill directories and cannot be renamed`);
}
const mdDataBeforeMove = parseMdFile(existing.path);
const frontmatterName = typeof mdDataBeforeMove.frontmatter?.name === 'string'
? mdDataBeforeMove.frontmatter.name
: oldName;
if (frontmatterName !== oldName) {
throw new Error(`Skill "${oldName}" does not match ${existing.path}`);
}
const conflict = getSkillScope(newName, workingDirectory);
if (conflict.path) {
throw new Error(`Skill ${newName} already exists at ${conflict.path}`);
}
const oldDir = path.dirname(existing.path);
const newDir = path.join(path.dirname(oldDir), newName);
const directoriesDiffer = path.resolve(oldDir) !== path.resolve(newDir);
if (directoriesDiffer && fs.existsSync(newDir)) {
throw new Error(`Skill directory already exists at ${newDir}`);
}
// Rename the skill directory in place so supporting files and SKILL.md body are preserved.
if (directoriesDiffer) {
fs.renameSync(oldDir, newDir);
}
const newPath = path.join(newDir, 'SKILL.md');
try {
const mdData = parseMdFile(newPath);
mdData.frontmatter = {
...mdData.frontmatter,
name: newName,
};
writeMdFile(newPath, mdData.frontmatter, mdData.body);
} catch (error) {
if (directoriesDiffer && fs.existsSync(newDir) && !fs.existsSync(oldDir)) {
try {
fs.renameSync(newDir, oldDir);
} catch (rollbackError) {
console.error(`Failed to rollback skill rename from ${newDir} to ${oldDir}:`, rollbackError);
}
}
throw error;
}
console.log(`Renamed skill: ${oldName} -> ${newName} (path: ${newPath})`);
}
export {
getSkillSources,
discoverSkills,
@@ -599,4 +733,6 @@ export {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
};
+196 -1
View File
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import fsPromises from 'fs/promises';
import os from 'os';
import path from 'path';
import { discoverSkills, getSkillSources, mergeDiscoveredSkills } from './skills.js';
import { discoverSkills, getSkillSources, mergeDiscoveredSkills, renameSkill } from './skills.js';
describe('skills', () => {
it('merges locally discovered skills missing from OpenCode live discovery', () => {
@@ -147,4 +148,198 @@ describe('skills', () => {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('renames a skill directory while preserving SKILL.md body and supporting files', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-'));
const projectRoot = path.join(tempRoot, 'project');
const skillDir = path.join(projectRoot, '.opencode', 'skills', 'original-skill');
const skillPath = path.join(skillDir, 'SKILL.md');
const supportPath = path.join(skillDir, 'notes.md');
const body = [
'# Original Skill',
'',
'Preserve this non-trivial body across rename.',
'',
'## Details',
'',
'- step one',
'- step two',
].join('\n');
try {
await fsPromises.mkdir(skillDir, { recursive: true });
await fsPromises.writeFile(
skillPath,
[
'---',
'name: original-skill',
'description: Original skill description',
'license: MIT',
'---',
'',
body,
'',
].join('\n'),
'utf8',
);
await fsPromises.writeFile(supportPath, 'supporting file contents\n', 'utf8');
renameSkill('original-skill', 'renamed-skill', projectRoot);
const renamedDir = path.join(projectRoot, '.opencode', 'skills', 'renamed-skill');
const renamedPath = path.join(renamedDir, 'SKILL.md');
const renamedSupportPath = path.join(renamedDir, 'notes.md');
expect(fs.existsSync(skillDir)).toBe(false);
expect(fs.existsSync(renamedPath)).toBe(true);
expect(fs.existsSync(renamedSupportPath)).toBe(true);
const sources = getSkillSources('renamed-skill', projectRoot, {
name: 'renamed-skill',
path: renamedPath,
scope: 'project',
source: 'opencode',
description: 'fallback',
});
expect(sources.md.exists).toBe(true);
expect(sources.md.name).toBe('renamed-skill');
expect(sources.md.description).toBe('Original skill description');
expect(sources.md.instructions).toBe(body);
expect(await fsPromises.readFile(renamedSupportPath, 'utf8')).toBe('supporting file contents\n');
const raw = await fsPromises.readFile(renamedPath, 'utf8');
expect(raw).toContain('license: MIT');
expect(raw).not.toContain('Renamed skill');
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('rolls back the directory rename when frontmatter write fails', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-rollback-'));
const projectRoot = path.join(tempRoot, 'project');
const skillDir = path.join(projectRoot, '.opencode', 'skills', 'rollback-skill');
const skillPath = path.join(skillDir, 'SKILL.md');
const body = '# Rollback body\n\nMust remain in the original directory.';
try {
await fsPromises.mkdir(skillDir, { recursive: true });
await fsPromises.writeFile(
skillPath,
[
'---',
'name: rollback-skill',
'description: Rollback skill',
'---',
'',
body,
'',
].join('\n'),
'utf8',
);
await fsPromises.chmod(skillPath, 0o444);
expect(() => renameSkill('rollback-skill', 'rollback-skill-renamed', projectRoot)).toThrow();
expect(fs.existsSync(skillDir)).toBe(true);
expect(fs.existsSync(path.join(projectRoot, '.opencode', 'skills', 'rollback-skill-renamed'))).toBe(false);
expect(await fsPromises.readFile(skillPath, 'utf8')).toContain(body);
} finally {
try {
await fsPromises.chmod(skillPath, 0o644);
} catch {
// Best-effort cleanup when the file was rolled back under a different mode.
}
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('rejects invalid names, missing skills, conflicts, unmanaged paths, and frontmatter mismatches', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-reject-'));
const projectRoot = path.join(tempRoot, 'project');
const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-skill');
const conflictDir = path.join(projectRoot, '.opencode', 'skills', 'taken-name');
const mismatchDir = path.join(projectRoot, '.opencode', 'skills', 'folder-name');
const cacheStamp = `oc-rename-${Date.now()}`;
const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-skill');
try {
await fsPromises.mkdir(managedDir, { recursive: true });
await fsPromises.writeFile(
path.join(managedDir, 'SKILL.md'),
[
'---',
'name: managed-skill',
'description: Managed',
'---',
'',
'Managed body',
'',
].join('\n'),
'utf8',
);
await fsPromises.mkdir(conflictDir, { recursive: true });
await fsPromises.writeFile(
path.join(conflictDir, 'SKILL.md'),
[
'---',
'name: taken-name',
'description: Taken',
'---',
'',
'Taken body',
'',
].join('\n'),
'utf8',
);
await fsPromises.mkdir(mismatchDir, { recursive: true });
await fsPromises.writeFile(
path.join(mismatchDir, 'SKILL.md'),
[
'---',
'name: frontmatter-name',
'description: Mismatch',
'---',
'',
'Mismatch body',
'',
].join('\n'),
'utf8',
);
await fsPromises.mkdir(cacheDir, { recursive: true });
await fsPromises.writeFile(
path.join(cacheDir, 'SKILL.md'),
[
'---',
'name: cache-skill',
'description: Cache skill',
'---',
'',
'Cache body',
'',
].join('\n'),
'utf8',
);
expect(() => renameSkill('managed-skill', 'Invalid_Name', projectRoot)).toThrow(/Invalid skill name/);
expect(() => renameSkill('missing-skill', 'new-skill', projectRoot)).toThrow(/not found/);
expect(() => renameSkill('managed-skill', 'taken-name', projectRoot)).toThrow(/already exists/);
expect(() => renameSkill('folder-name', 'renamed-mismatch', projectRoot)).toThrow(/does not match/);
expect(() => renameSkill('cache-skill', 'cache-skill-renamed', projectRoot)).toThrow(/managed skill directories/);
expect(fs.existsSync(managedDir)).toBe(true);
expect(fs.existsSync(cacheDir)).toBe(true);
expect(fs.existsSync(path.join(projectRoot, '.opencode', 'skills', 'renamed-mismatch'))).toBe(false);
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
await fsPromises.rm(path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp), {
recursive: true,
force: true,
});
}
});
});