Skip to content

Commit a2f09b0

Browse files
committed
fix: make mode export/import paths slug-independent to support renaming
- Export now creates relative paths from the rules directory (e.g., "rule.md" instead of "rules-slug/rule.md") - Import strips old format prefixes for backwards compatibility - Users can now rename modes by editing the slug in exported YAML files - Added comprehensive tests for slug renaming scenarios Fixes #6229
1 parent 0504199 commit a2f09b0

File tree

2 files changed

+170
-8
lines changed

2 files changed

+170
-8
lines changed

src/core/config/CustomModesManager.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -782,10 +782,9 @@ export class CustomModesManager {
782782
const filePath = path.join(modeRulesDir, entry.name)
783783
const content = await fs.readFile(filePath, "utf-8")
784784
if (content.trim()) {
785-
// Calculate relative path based on mode source
786-
const relativePath = isGlobalMode
787-
? path.relative(baseDir, filePath)
788-
: path.relative(path.join(baseDir, ".roo"), filePath)
785+
// Calculate relative path from the mode's rules directory
786+
// This makes the path slug-independent (e.g., "rule.md" instead of "rules-slug/rule.md")
787+
const relativePath = path.relative(modeRulesDir, filePath)
789788
rulesFiles.push({ relativePath, content: content.trim() })
790789
}
791790
}
@@ -872,20 +871,31 @@ export class CustomModesManager {
872871
// Import the new rules files with path validation
873872
for (const ruleFile of rulesFiles) {
874873
if (ruleFile.relativePath && ruleFile.content) {
874+
// Strip rules-<slug> prefix for backwards compatibility
875+
let cleanRelativePath = ruleFile.relativePath
876+
const rulesPrefix = cleanRelativePath.match(/^rules-[^\/]+\/(.*)$/)
877+
if (rulesPrefix) {
878+
cleanRelativePath = rulesPrefix[1]
879+
logger.info(
880+
`Stripping old format prefix from path: ${ruleFile.relativePath} -> ${cleanRelativePath}`,
881+
)
882+
}
883+
875884
// Validate the relative path to prevent path traversal attacks
876-
const normalizedRelativePath = path.normalize(ruleFile.relativePath)
885+
const normalizedRelativePath = path.normalize(cleanRelativePath)
877886

878887
// Ensure the path doesn't contain traversal sequences
879888
if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
880889
logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
881890
continue // Skip this file but continue with others
882891
}
883892

884-
const targetPath = path.join(baseDir, normalizedRelativePath)
893+
// Use the rules folder path as base, not the general base directory
894+
const targetPath = path.join(rulesFolderPath, normalizedRelativePath)
885895
const normalizedTargetPath = path.normalize(targetPath)
886-
const expectedBasePath = path.normalize(baseDir)
896+
const expectedBasePath = path.normalize(rulesFolderPath)
887897

888-
// Ensure the resolved path stays within the base directory
898+
// Ensure the resolved path stays within the rules folder
889899
if (!normalizedTargetPath.startsWith(expectedBasePath)) {
890900
logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
891901
continue // Skip this file but continue with others

src/core/config/__tests__/CustomModesManager.spec.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,6 +1235,145 @@ describe("CustomModesManager", () => {
12351235
const newRulePath = Object.keys(writtenFiles).find((p) => p.includes("new-rule.md"))
12361236
expect(writtenFiles[newRulePath!]).toBe("New rule content")
12371237
})
1238+
1239+
it("should handle slug renaming during import (backwards compatibility)", async () => {
1240+
// Export YAML with old format (includes rules-old-slug prefix)
1241+
const exportYamlOldFormat = yaml.stringify({
1242+
customModes: [
1243+
{
1244+
slug: "new-slug", // User changed this from "old-slug"
1245+
name: "Renamed Mode",
1246+
roleDefinition: "Test Role",
1247+
groups: ["read"],
1248+
rulesFiles: [
1249+
{
1250+
// Old format: includes rules-old-slug prefix
1251+
relativePath: "rules-old-slug/rule1.md",
1252+
content: "Rule 1 content",
1253+
},
1254+
{
1255+
relativePath: "rules-old-slug/subfolder/rule2.md",
1256+
content: "Rule 2 content",
1257+
},
1258+
],
1259+
},
1260+
],
1261+
})
1262+
1263+
let roomodesContent: any = null
1264+
let writtenFiles: Record<string, string> = {}
1265+
;(fs.readFile as Mock).mockImplementation(async (path: string) => {
1266+
if (path === mockSettingsPath) {
1267+
return yaml.stringify({ customModes: [] })
1268+
}
1269+
if (path === mockRoomodes && roomodesContent) {
1270+
return yaml.stringify(roomodesContent)
1271+
}
1272+
throw new Error("File not found")
1273+
})
1274+
;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string) => {
1275+
if (path === mockRoomodes) {
1276+
roomodesContent = yaml.parse(content)
1277+
} else {
1278+
writtenFiles[path] = content
1279+
}
1280+
return Promise.resolve()
1281+
})
1282+
;(fs.mkdir as Mock).mockResolvedValue(undefined)
1283+
;(fs.rm as Mock).mockResolvedValue(undefined)
1284+
1285+
const result = await manager.importModeWithRules(exportYamlOldFormat)
1286+
1287+
expect(result.success).toBe(true)
1288+
1289+
// Verify mode was imported with new slug
1290+
expect(roomodesContent.customModes[0].slug).toBe("new-slug")
1291+
1292+
// Verify rules files were created in the NEW slug's directory
1293+
expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining(path.join(".roo", "rules-new-slug")), {
1294+
recursive: true,
1295+
})
1296+
1297+
// Verify files are in the correct location (rules-new-slug, not rules-old-slug)
1298+
const rule1Path = Object.keys(writtenFiles).find((p) => p.includes("rule1.md"))
1299+
const rule2Path = Object.keys(writtenFiles).find((p) => p.includes("rule2.md"))
1300+
1301+
expect(rule1Path).toContain("rules-new-slug")
1302+
expect(rule1Path).not.toContain("rules-old-slug")
1303+
expect(rule2Path).toContain("rules-new-slug")
1304+
expect(rule2Path).not.toContain("rules-old-slug")
1305+
1306+
// Verify content is preserved
1307+
expect(writtenFiles[rule1Path!]).toBe("Rule 1 content")
1308+
expect(writtenFiles[rule2Path!]).toBe("Rule 2 content")
1309+
})
1310+
1311+
it("should handle new export format without rules prefix", async () => {
1312+
// Export YAML with new format (no rules-slug prefix)
1313+
const exportYamlNewFormat = yaml.stringify({
1314+
customModes: [
1315+
{
1316+
slug: "test-mode",
1317+
name: "Test Mode",
1318+
roleDefinition: "Test Role",
1319+
groups: ["read"],
1320+
rulesFiles: [
1321+
{
1322+
// New format: no prefix, just relative path within rules directory
1323+
relativePath: "rule1.md",
1324+
content: "Rule 1 content",
1325+
},
1326+
{
1327+
relativePath: "subfolder/rule2.md",
1328+
content: "Rule 2 content",
1329+
},
1330+
],
1331+
},
1332+
],
1333+
})
1334+
1335+
let roomodesContent: any = null
1336+
let writtenFiles: Record<string, string> = {}
1337+
;(fs.readFile as Mock).mockImplementation(async (path: string) => {
1338+
if (path === mockSettingsPath) {
1339+
return yaml.stringify({ customModes: [] })
1340+
}
1341+
if (path === mockRoomodes && roomodesContent) {
1342+
return yaml.stringify(roomodesContent)
1343+
}
1344+
throw new Error("File not found")
1345+
})
1346+
;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string) => {
1347+
if (path === mockRoomodes) {
1348+
roomodesContent = yaml.parse(content)
1349+
} else {
1350+
writtenFiles[path] = content
1351+
}
1352+
return Promise.resolve()
1353+
})
1354+
;(fs.mkdir as Mock).mockResolvedValue(undefined)
1355+
;(fs.rm as Mock).mockResolvedValue(undefined)
1356+
1357+
const result = await manager.importModeWithRules(exportYamlNewFormat)
1358+
1359+
expect(result.success).toBe(true)
1360+
1361+
// Verify rules files were created in the correct directory
1362+
expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining(path.join(".roo", "rules-test-mode")), {
1363+
recursive: true,
1364+
})
1365+
1366+
// Verify files are in the correct location
1367+
const rule1Path = Object.keys(writtenFiles).find((p) => p.includes("rule1.md"))
1368+
const rule2Path = Object.keys(writtenFiles).find((p) => p.includes("rule2.md"))
1369+
1370+
expect(rule1Path).toContain("rules-test-mode")
1371+
expect(rule2Path).toContain(path.join("rules-test-mode", "subfolder"))
1372+
1373+
// Verify content is preserved
1374+
expect(writtenFiles[rule1Path!]).toBe("Rule 1 content")
1375+
expect(writtenFiles[rule2Path!]).toBe("Rule 2 content")
1376+
})
12381377
})
12391378
})
12401379

@@ -1491,6 +1630,13 @@ describe("CustomModesManager", () => {
14911630
expect(result.yaml).toContain("test-mode")
14921631
expect(result.yaml).toContain("Existing instructions")
14931632
expect(result.yaml).toContain("New rule content from files")
1633+
1634+
// Parse the YAML to check the relativePath format
1635+
const exportedData = yaml.parse(result.yaml!)
1636+
const rulesFiles = exportedData.customModes[0].rulesFiles
1637+
expect(rulesFiles).toBeDefined()
1638+
expect(rulesFiles[0].relativePath).toBe("rule1.md") // Should NOT include "rules-test-mode/" prefix
1639+
14941640
// Should NOT delete the rules directory
14951641
expect(fs.rm).not.toHaveBeenCalled()
14961642
})
@@ -1616,6 +1762,12 @@ describe("CustomModesManager", () => {
16161762
expect(result.yaml).toContain("global-test-mode")
16171763
expect(result.yaml).toContain("Global Test Mode")
16181764
expect(result.yaml).toContain("Global rule content")
1765+
1766+
// Parse the YAML to check the relativePath format
1767+
const exportedData = yaml.parse(result.yaml!)
1768+
const rulesFiles = exportedData.customModes[0].rulesFiles
1769+
expect(rulesFiles).toBeDefined()
1770+
expect(rulesFiles[0].relativePath).toBe("rule1.md") // Should NOT include "rules-global-test-mode/" prefix
16191771
})
16201772

16211773
it("should successfully export global mode without rules when global rules directory doesn't exist", async () => {

0 commit comments

Comments
 (0)