diff --git a/packages/types/src/marketplace.ts b/packages/types/src/marketplace.ts index f2821e1b74..b83322d5ad 100644 --- a/packages/types/src/marketplace.ts +++ b/packages/types/src/marketplace.ts @@ -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 diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts index 88dcbb9574..aac82b5172 100644 --- a/packages/types/src/mode.ts +++ b/packages/types/src/mode.ts @@ -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 diff --git a/src/services/marketplace/SimpleInstaller.ts b/src/services/marketplace/SimpleInstaller.ts index be002e2f1d..f847d695f5 100644 --- a/src/services/marketplace/SimpleInstaller.ts +++ b/src/services/marketplace/SimpleInstaller.ts @@ -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 diff --git a/src/services/marketplace/__tests__/SimpleInstaller.submodes.spec.ts b/src/services/marketplace/__tests__/SimpleInstaller.submodes.spec.ts new file mode 100644 index 0000000000..396b027ea1 --- /dev/null +++ b/src/services/marketplace/__tests__/SimpleInstaller.submodes.spec.ts @@ -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) + }) + }) +})