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:
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user