feat: add marketplace support for bundled submodes

- Add submodes field to ModeMarketplaceItem type for bundling submodes
- Update SimpleInstaller to handle parent-submode relationships during install
- Auto-set parent and hidden properties on submodes during installation
- Delete submodes when removing parent mode from marketplace
- Add comprehensive tests for submode marketplace functionality

This enables complex modes to be distributed with their submodes as a single marketplace item, addressing the requirement for modes containing submodes to be downloaded together.
This commit is contained in:
Roo Code 2025-11-21 14:21:20 +00:00
parent 038f830bf6
commit c803c3be82
4 changed files with 508 additions and 6 deletions

View file

@ -49,6 +49,8 @@ const baseMarketplaceItemSchema = z.object({
*/
export const modeMarketplaceItemSchema = baseMarketplaceItemSchema.extend({
content: z.string().min(1), // YAML content for modes
// Support for bundled submodes - when a mode has submodes, they are included here
submodes: z.array(z.string()).optional(), // Array of submode YAML content strings
})
export type ModeMarketplaceItem = z.infer<typeof modeMarketplaceItemSchema>

View file

@ -70,6 +70,9 @@ export const modeConfigSchema = z.object({
customInstructions: z.string().optional(),
groups: groupEntryArraySchema,
source: z.enum(["global", "project"]).optional(),
// Support for hidden submodes
hidden: z.boolean().optional(),
parent: z.string().optional(), // The parent mode slug if this is a submode
})
export type ModeConfig = z.infer<typeof modeConfigSchema>

View file

@ -46,9 +46,30 @@ export class SimpleInstaller {
// If CustomModesManager is available, use importModeWithRules
if (this.customModesManager) {
// Parse the main mode content
const mainMode = yaml.parse(item.content)
const modesToImport = [mainMode]
// Check if this mode has bundled submodes
if (item.type === "mode" && item.submodes && Array.isArray(item.submodes)) {
// Parse each submode and add parent/hidden properties
for (const submodeContent of item.submodes) {
try {
const submode = yaml.parse(submodeContent)
// Ensure submodes have the parent relationship and hidden flag set
submode.parent = mainMode.slug
submode.hidden = true
modesToImport.push(submode)
} catch (error) {
console.warn(`Failed to parse submode content: ${error}`)
// Continue with other submodes if one fails
}
}
}
// Transform marketplace content to import format (wrap in customModes array)
const importData = {
customModes: [yaml.parse(item.content)],
customModes: modesToImport,
}
const importYaml = yaml.stringify(importData)
@ -67,12 +88,11 @@ export class SimpleInstaller {
try {
const fileContent = await fs.readFile(filePath, "utf-8")
const lines = fileContent.split("\n")
const modeData = yaml.parse(item.content)
// Find the line containing the slug of the added mode
if (modeData?.slug) {
if (mainMode?.slug) {
const slugLineIndex = lines.findIndex(
(l) => l.includes(`slug: ${modeData.slug}`) || l.includes(`slug: "${modeData.slug}"`),
(l) => l.includes(`slug: ${mainMode.slug}`) || l.includes(`slug: "${mainMode.slug}"`),
)
if (slugLineIndex >= 0) {
line = slugLineIndex + 1 // Convert to 1-based line number
@ -319,9 +339,21 @@ export class SimpleInstaller {
throw new Error("Mode missing slug identifier")
}
// Get the current modes to determine the source
// Get the current modes to check for submodes
const modes = await this.customModesManager.getCustomModes()
const mode = modes.find((m) => m.slug === modeSlug)
// Find all submodes that belong to this parent mode
const submodesToDelete = modes.filter((m) => m.parent === modeSlug && m.hidden)
// Delete submodes first (if any)
for (const submode of submodesToDelete) {
try {
await this.customModesManager.deleteCustomMode(submode.slug, true)
} catch (error) {
console.warn(`Failed to delete submode ${submode.slug}: ${error}`)
// Continue with other deletions even if one fails
}
}
// Use CustomModesManager to delete the mode configuration
// This also handles rules folder deletion

View file

@ -0,0 +1,465 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as path from "path"
import * as yaml from "yaml"
import type { MarketplaceItem } from "@roo-code/types"
import { SimpleInstaller } from "../SimpleInstaller"
import type { CustomModesManager } from "../../../core/config/CustomModesManager"
vi.mock("vscode", () => ({
workspace: {
workspaceFolders: [
{
uri: {
fsPath: "/test/workspace",
},
},
],
},
}))
vi.mock("fs/promises")
vi.mock("../../../utils/globalContext", () => ({
ensureSettingsDirectoryExists: vi.fn(),
}))
vi.mock("../../../shared/globalFileNames", () => ({
GlobalFileNames: {
customModes: "custom-modes.yaml",
mcpSettings: "mcp-settings.json",
},
}))
// Import the mocked module to ensure it's available
import { ensureSettingsDirectoryExists } from "../../../utils/globalContext"
describe("SimpleInstaller - Submodes Support", () => {
let installer: SimpleInstaller
let mockCustomModesManager: CustomModesManager
let mockContext: vscode.ExtensionContext
beforeEach(() => {
// Reset mocks
vi.clearAllMocks()
// Set up the mock to return a valid path
vi.mocked(ensureSettingsDirectoryExists).mockResolvedValue("/test/.roo")
// Mock extension context
mockContext = {
globalStorageUri: {
fsPath: "/test/.roo",
},
} as any
// Mock CustomModesManager
mockCustomModesManager = {
importModeWithRules: vi.fn(),
deleteCustomMode: vi.fn(),
getCustomModes: vi.fn(),
} as any
installer = new SimpleInstaller(mockContext, mockCustomModesManager)
// Default file system mocks
vi.mocked(fs.readFile).mockResolvedValue("")
vi.mocked(fs.writeFile).mockResolvedValue()
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("installMode with submodes", () => {
it("should install a mode with bundled submodes", async () => {
const mockModeItem: MarketplaceItem = {
type: "mode",
id: "complex-mode",
name: "Complex Mode",
description: "A mode with submodes",
content: yaml.stringify({
slug: "complex-mode",
name: "Complex Mode",
roleDefinition: "Main mode role",
groups: ["read", "edit"],
}),
submodes: [
yaml.stringify({
slug: "submode-1",
name: "Submode 1",
roleDefinition: "Submode 1 role",
groups: ["read"],
}),
yaml.stringify({
slug: "submode-2",
name: "Submode 2",
roleDefinition: "Submode 2 role",
groups: ["edit"],
}),
],
}
vi.mocked(mockCustomModesManager.importModeWithRules).mockResolvedValue({
success: true,
slug: "complex-mode",
})
vi.mocked(fs.readFile).mockResolvedValue(
yaml.stringify({
customModes: [
{
slug: "complex-mode",
name: "Complex Mode",
roleDefinition: "Main mode role",
groups: ["read", "edit"],
},
{
slug: "submode-1",
name: "Submode 1",
roleDefinition: "Submode 1 role",
groups: ["read"],
parent: "complex-mode",
hidden: true,
},
{
slug: "submode-2",
name: "Submode 2",
roleDefinition: "Submode 2 role",
groups: ["edit"],
parent: "complex-mode",
hidden: true,
},
],
}),
)
const result = await installer.installItem(mockModeItem, { target: "project" })
// Verify that importModeWithRules was called with all modes
expect(mockCustomModesManager.importModeWithRules).toHaveBeenCalledTimes(1)
const importCall = vi.mocked(mockCustomModesManager.importModeWithRules).mock.calls[0]
const importedData = yaml.parse(importCall[0])
// Check that we have 3 modes total
expect(importedData.customModes).toHaveLength(3)
// Verify main mode
expect(importedData.customModes[0].slug).toBe("complex-mode")
// Verify submodes have parent and hidden properties
expect(importedData.customModes[1].slug).toBe("submode-1")
expect(importedData.customModes[1].parent).toBe("complex-mode")
expect(importedData.customModes[1].hidden).toBe(true)
expect(importedData.customModes[2].slug).toBe("submode-2")
expect(importedData.customModes[2].parent).toBe("complex-mode")
expect(importedData.customModes[2].hidden).toBe(true)
// Verify the file path was returned
expect(result.filePath).toBe(path.join("/test/workspace", ".roomodes"))
})
it("should handle mode with no submodes", async () => {
const mockModeItem: MarketplaceItem = {
type: "mode",
id: "simple-mode",
name: "Simple Mode",
description: "A mode without submodes",
content: yaml.stringify({
slug: "simple-mode",
name: "Simple Mode",
roleDefinition: "Simple mode role",
groups: ["read"],
}),
// No submodes property
}
vi.mocked(mockCustomModesManager.importModeWithRules).mockResolvedValue({
success: true,
slug: "simple-mode",
})
vi.mocked(fs.readFile).mockResolvedValue(
yaml.stringify({
customModes: [
{
slug: "simple-mode",
name: "Simple Mode",
roleDefinition: "Simple mode role",
groups: ["read"],
},
],
}),
)
const result = await installer.installItem(mockModeItem, { target: "global" })
const importCall = vi.mocked(mockCustomModesManager.importModeWithRules).mock.calls[0]
const importedData = yaml.parse(importCall[0])
// Should only have the main mode
expect(importedData.customModes).toHaveLength(1)
expect(importedData.customModes[0].slug).toBe("simple-mode")
// Verify the file path was returned
expect(result.filePath).toBe(path.join("/test/.roo", "custom-modes.yaml"))
})
it("should handle invalid submode YAML gracefully", async () => {
const mockModeItem: MarketplaceItem = {
type: "mode",
id: "mode-with-bad-submode",
name: "Mode with Bad Submode",
description: "Has an invalid submode",
content: yaml.stringify({
slug: "main-mode",
name: "Main Mode",
roleDefinition: "Main role",
groups: ["read"],
}),
submodes: [
"this is not valid YAML {{{",
yaml.stringify({
slug: "valid-submode",
name: "Valid Submode",
roleDefinition: "Valid role",
groups: ["edit"],
}),
],
}
vi.mocked(mockCustomModesManager.importModeWithRules).mockResolvedValue({
success: true,
slug: "main-mode",
})
await installer.installItem(mockModeItem, { target: "project" })
const importCall = vi.mocked(mockCustomModesManager.importModeWithRules).mock.calls[0]
const importedData = yaml.parse(importCall[0])
// Should have main mode and valid submode only
expect(importedData.customModes).toHaveLength(2)
expect(importedData.customModes[0].slug).toBe("main-mode")
expect(importedData.customModes[1].slug).toBe("valid-submode")
})
})
describe("removeMode with submodes", () => {
it("should remove a mode and its submodes", async () => {
const mockModeItem: MarketplaceItem = {
type: "mode",
id: "parent-mode",
name: "Parent Mode",
description: "Mode with submodes",
content: yaml.stringify({
slug: "parent-mode",
name: "Parent Mode",
roleDefinition: "Parent role",
groups: ["read"],
}),
}
// Mock getCustomModes to return parent and submodes
vi.mocked(mockCustomModesManager.getCustomModes).mockResolvedValue([
{
slug: "parent-mode",
name: "Parent Mode",
roleDefinition: "Parent role",
groups: ["read"],
},
{
slug: "submode-a",
name: "Submode A",
roleDefinition: "Submode A role",
groups: ["edit"],
parent: "parent-mode",
hidden: true,
},
{
slug: "submode-b",
name: "Submode B",
roleDefinition: "Submode B role",
groups: ["command"],
parent: "parent-mode",
hidden: true,
},
{
slug: "unrelated-mode",
name: "Unrelated Mode",
roleDefinition: "Unrelated role",
groups: ["read"],
},
] as any)
vi.mocked(mockCustomModesManager.deleteCustomMode).mockResolvedValue()
await installer.removeItem(mockModeItem, { target: "project" })
// Should delete submodes first, then parent
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledTimes(3)
// Verify submodes were deleted
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledWith("submode-a", true)
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledWith("submode-b", true)
// Verify parent was deleted
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledWith("parent-mode", true)
})
it("should handle mode without submodes", async () => {
const mockModeItem: MarketplaceItem = {
type: "mode",
id: "standalone-mode",
name: "Standalone Mode",
description: "Mode without submodes",
content: yaml.stringify({
slug: "standalone-mode",
name: "Standalone Mode",
roleDefinition: "Standalone role",
groups: ["read"],
}),
}
vi.mocked(mockCustomModesManager.getCustomModes).mockResolvedValue([
{
slug: "standalone-mode",
name: "Standalone Mode",
roleDefinition: "Standalone role",
groups: ["read"],
},
{
slug: "other-mode",
name: "Other Mode",
roleDefinition: "Other role",
groups: ["edit"],
},
] as any)
vi.mocked(mockCustomModesManager.deleteCustomMode).mockResolvedValue()
await installer.removeItem(mockModeItem, { target: "global" })
// Should only delete the standalone mode
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledTimes(1)
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledWith("standalone-mode", true)
})
it("should continue deletion even if submode deletion fails", async () => {
const mockModeItem: MarketplaceItem = {
type: "mode",
id: "parent-with-failing-submode",
name: "Parent Mode",
description: "Mode with problematic submode",
content: yaml.stringify({
slug: "parent-mode",
name: "Parent Mode",
roleDefinition: "Parent role",
groups: ["read"],
}),
}
vi.mocked(mockCustomModesManager.getCustomModes).mockResolvedValue([
{
slug: "parent-mode",
name: "Parent Mode",
roleDefinition: "Parent role",
groups: ["read"],
},
{
slug: "failing-submode",
name: "Failing Submode",
roleDefinition: "Failing role",
groups: ["edit"],
parent: "parent-mode",
hidden: true,
},
{
slug: "working-submode",
name: "Working Submode",
roleDefinition: "Working role",
groups: ["command"],
parent: "parent-mode",
hidden: true,
},
] as any)
// Make the first submode deletion fail
vi.mocked(mockCustomModesManager.deleteCustomMode)
.mockRejectedValueOnce(new Error("Failed to delete submode"))
.mockResolvedValue()
await installer.removeItem(mockModeItem, { target: "project" })
// Should still attempt to delete all modes
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledTimes(3)
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledWith("failing-submode", true)
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledWith("working-submode", true)
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledWith("parent-mode", true)
})
})
describe("edge cases", () => {
it("should only delete hidden submodes with matching parent", async () => {
const mockModeItem: MarketplaceItem = {
type: "mode",
id: "parent-mode",
name: "Parent Mode",
description: "Mode to remove",
content: yaml.stringify({
slug: "parent-mode",
name: "Parent Mode",
roleDefinition: "Parent role",
groups: ["read"],
}),
}
vi.mocked(mockCustomModesManager.getCustomModes).mockResolvedValue([
{
slug: "parent-mode",
name: "Parent Mode",
roleDefinition: "Parent role",
groups: ["read"],
},
{
slug: "visible-submode",
name: "Visible Submode",
roleDefinition: "Visible role",
groups: ["edit"],
parent: "parent-mode",
// No hidden flag - should not be deleted
},
{
slug: "hidden-different-parent",
name: "Hidden Different Parent",
roleDefinition: "Different parent role",
groups: ["command"],
parent: "other-parent",
hidden: true,
// Different parent - should not be deleted
},
{
slug: "hidden-correct-parent",
name: "Hidden Correct Parent",
roleDefinition: "Correct parent role",
groups: ["mcp"],
parent: "parent-mode",
hidden: true,
// Correct parent and hidden - should be deleted
},
] as any)
vi.mocked(mockCustomModesManager.deleteCustomMode).mockResolvedValue()
await installer.removeItem(mockModeItem, { target: "project" })
// Should only delete the parent and the correctly matched hidden submode
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledTimes(2)
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledWith("hidden-correct-parent", true)
expect(mockCustomModesManager.deleteCustomMode).toHaveBeenCalledWith("parent-mode", true)
})
})
})