diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index e713cafa4c..c6a0152d93 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -105,6 +105,8 @@ export const globalSettingsSchema = z.object({ historyPreviewCollapsed: z.boolean().optional(), profileThresholds: z.record(z.string(), z.number()).optional(), hasOpenedModeSelector: z.boolean().optional(), + lastModeExportPath: z.string().optional(), + lastModeImportPath: z.string().optional(), }) export type GlobalSettings = z.infer diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index b96293ee49..9f29185eba 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -5,10 +5,11 @@ import * as fs from "fs/promises" import * as yaml from "yaml" import stripBom from "strip-bom" -import { type ModeConfig, customModesSettingsSchema } from "@roo-code/types" +import { type ModeConfig, type PromptComponent, customModesSettingsSchema, modeConfigSchema } from "@roo-code/types" import { fileExistsAtPath } from "../../utils/fs" import { getWorkspacePath } from "../../utils/path" +import { getGlobalRooDirectory } from "../../services/roo-config" import { logger } from "../../utils/logging" import { GlobalFileNames } from "../../shared/globalFileNames" import { ensureSettingsDirectoryExists } from "../../utils/globalContext" @@ -16,6 +17,31 @@ import { t } from "../../i18n" const ROOMODES_FILENAME = ".roomodes" +// Type definitions for import/export functionality +interface RuleFile { + relativePath: string + content: string +} + +interface ExportedModeConfig extends ModeConfig { + rulesFiles?: RuleFile[] +} + +interface ImportData { + customModes: ExportedModeConfig[] +} + +interface ExportResult { + success: boolean + yaml?: string + error?: string +} + +interface ImportResult { + success: boolean + error?: string +} + export class CustomModesManager { private static readonly cacheTTL = 10_000 @@ -501,6 +527,383 @@ export class CustomModesManager { } } + /** + * Checks if a mode has associated rules files in the .roo/rules-{slug}/ directory + * @param slug - The mode identifier to check + * @returns True if the mode has rules files with content, false otherwise + */ + public async checkRulesDirectoryHasContent(slug: string): Promise { + try { + // Get workspace path + const workspacePath = getWorkspacePath() + if (!workspacePath) { + return false + } + + // Check if .roomodes file exists and contains this mode + // This ensures we can only consolidate rules for modes that have been customized + const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME) + try { + const roomodesExists = await fileExistsAtPath(roomodesPath) + if (roomodesExists) { + const roomodesContent = await fs.readFile(roomodesPath, "utf-8") + const roomodesData = yaml.parse(roomodesContent) + const roomodesModes = roomodesData?.customModes || [] + + // Check if this specific mode exists in .roomodes + const modeInRoomodes = roomodesModes.find((m: any) => m.slug === slug) + if (!modeInRoomodes) { + return false // Mode not customized in .roomodes, cannot consolidate + } + } else { + // If no .roomodes file exists, check if it's in global custom modes + const allModes = await this.getCustomModes() + const mode = allModes.find((m) => m.slug === slug) + + if (!mode) { + return false // Not a custom mode, cannot consolidate + } + } + } catch (error) { + // If we can't read .roomodes, fall back to checking custom modes + const allModes = await this.getCustomModes() + const mode = allModes.find((m) => m.slug === slug) + + if (!mode) { + return false // Not a custom mode, cannot consolidate + } + } + + // Check for .roo/rules-{slug}/ directory + const modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`) + + try { + const stats = await fs.stat(modeRulesDir) + if (!stats.isDirectory()) { + return false + } + } catch (error) { + return false + } + + // Check if directory has any content files + try { + const entries = await fs.readdir(modeRulesDir, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.isFile()) { + // Use path.join with modeRulesDir and entry.name for compatibility + const filePath = path.join(modeRulesDir, entry.name) + const content = await fs.readFile(filePath, "utf-8") + if (content.trim()) { + return true // Found at least one file with content + } + } + } + + return false // No files with content found + } catch (error) { + return false + } + } catch (error) { + logger.error("Failed to check rules directory for mode", { + slug, + error: error instanceof Error ? error.message : String(error), + }) + return false + } + } + + /** + * Exports a mode configuration with its associated rules files into a shareable YAML format + * @param slug - The mode identifier to export + * @param customPrompts - Optional custom prompts to merge into the export + * @returns Success status with YAML content or error message + */ + public async exportModeWithRules(slug: string, customPrompts?: PromptComponent): Promise { + try { + // Import modes from shared to check built-in modes + const { modes: builtInModes } = await import("../../shared/modes") + + // Get all current modes + const allModes = await this.getCustomModes() + let mode = allModes.find((m) => m.slug === slug) + + // If mode not found in custom modes, check if it's a built-in mode that has been customized + if (!mode) { + const workspacePath = getWorkspacePath() + if (!workspacePath) { + return { success: false, error: "No workspace found" } + } + + const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME) + try { + const roomodesExists = await fileExistsAtPath(roomodesPath) + if (roomodesExists) { + const roomodesContent = await fs.readFile(roomodesPath, "utf-8") + const roomodesData = yaml.parse(roomodesContent) + const roomodesModes = roomodesData?.customModes || [] + + // Find the mode in .roomodes + mode = roomodesModes.find((m: any) => m.slug === slug) + } + } catch (error) { + // Continue to check built-in modes + } + + // If still not found, check if it's a built-in mode + if (!mode) { + const builtInMode = builtInModes.find((m) => m.slug === slug) + if (builtInMode) { + // Use the built-in mode as the base + mode = { ...builtInMode } + } else { + return { success: false, error: "Mode not found" } + } + } + } + + // Get workspace path + const workspacePath = getWorkspacePath() + if (!workspacePath) { + return { success: false, error: "No workspace found" } + } + + // Check for .roo/rules-{slug}/ directory + const modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`) + + let rulesFiles: RuleFile[] = [] + try { + const stats = await fs.stat(modeRulesDir) + if (stats.isDirectory()) { + // Extract content specific to this mode by looking for the mode-specific rules + const entries = await fs.readdir(modeRulesDir, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.isFile()) { + // Use path.join with modeRulesDir and entry.name for compatibility + const filePath = path.join(modeRulesDir, entry.name) + const content = await fs.readFile(filePath, "utf-8") + if (content.trim()) { + // Calculate relative path from .roo directory + const relativePath = path.relative(path.join(workspacePath, ".roo"), filePath) + rulesFiles.push({ relativePath, content: content.trim() }) + } + } + } + } + } catch (error) { + // Directory doesn't exist, which is fine - mode might not have rules + } + + // Create an export mode with rules files preserved + const exportMode: ExportedModeConfig = { + ...mode, + // Remove source property for export + source: "project" as const, + } + + // Merge custom prompts if provided + if (customPrompts) { + if (customPrompts.roleDefinition) exportMode.roleDefinition = customPrompts.roleDefinition + if (customPrompts.description) exportMode.description = customPrompts.description + if (customPrompts.whenToUse) exportMode.whenToUse = customPrompts.whenToUse + if (customPrompts.customInstructions) exportMode.customInstructions = customPrompts.customInstructions + } + + // Add rules files if any exist + if (rulesFiles.length > 0) { + exportMode.rulesFiles = rulesFiles + } + + // Generate YAML + const exportData = { + customModes: [exportMode], + } + + const yamlContent = yaml.stringify(exportData) + + return { success: true, yaml: yamlContent } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to export mode with rules", { slug, error: errorMessage }) + return { success: false, error: errorMessage } + } + } + + /** + * Imports modes from YAML content, including their associated rules files + * @param yamlContent - The YAML content containing mode configurations + * @param source - Target level for import: "global" (all projects) or "project" (current workspace only) + * @returns Success status with optional error message + */ + public async importModeWithRules( + yamlContent: string, + source: "global" | "project" = "project", + ): Promise { + try { + // Parse the YAML content with proper type validation + let importData: ImportData + try { + const parsed = yaml.parse(yamlContent) + + // Validate the structure + if (!parsed?.customModes || !Array.isArray(parsed.customModes) || parsed.customModes.length === 0) { + return { success: false, error: "Invalid import format: Expected 'customModes' array in YAML" } + } + + importData = parsed as ImportData + } catch (parseError) { + return { + success: false, + error: `Invalid YAML format: ${parseError instanceof Error ? parseError.message : "Failed to parse YAML"}`, + } + } + + // Check workspace availability early if importing at project level + if (source === "project") { + const workspacePath = getWorkspacePath() + if (!workspacePath) { + return { success: false, error: "No workspace found" } + } + } + + // Process each mode in the import + for (const importMode of importData.customModes) { + const { rulesFiles, ...modeConfig } = importMode + + // Validate the mode configuration + const validationResult = modeConfigSchema.safeParse(modeConfig) + if (!validationResult.success) { + logger.error(`Invalid mode configuration for ${modeConfig.slug}`, { + errors: validationResult.error.errors, + }) + return { + success: false, + error: `Invalid mode configuration for ${modeConfig.slug}: ${validationResult.error.errors.map((e) => e.message).join(", ")}`, + } + } + + // Check for existing mode conflicts + const existingModes = await this.getCustomModes() + const existingMode = existingModes.find((m) => m.slug === importMode.slug) + if (existingMode) { + logger.info(`Overwriting existing mode: ${importMode.slug}`) + } + + // Import the mode configuration with the specified source + await this.updateCustomMode(importMode.slug, { + ...modeConfig, + source: source, // Use the provided source parameter + }) + + // Handle project-level imports + if (source === "project") { + const workspacePath = getWorkspacePath() + + // Always remove the existing rules folder for this mode if it exists + // This ensures that if the imported mode has no rules, the folder is cleaned up + const rulesFolderPath = path.join(workspacePath, ".roo", `rules-${importMode.slug}`) + try { + await fs.rm(rulesFolderPath, { recursive: true, force: true }) + logger.info(`Removed existing rules folder for mode ${importMode.slug}`) + } catch (error) { + // It's okay if the folder doesn't exist + logger.debug(`No existing rules folder to remove for mode ${importMode.slug}`) + } + + // Only create new rules files if they exist in the import + if (rulesFiles && Array.isArray(rulesFiles) && rulesFiles.length > 0) { + // Import the new rules files with path validation + for (const ruleFile of rulesFiles) { + if (ruleFile.relativePath && ruleFile.content) { + // Validate the relative path to prevent path traversal attacks + const normalizedRelativePath = path.normalize(ruleFile.relativePath) + + // Ensure the path doesn't contain traversal sequences + if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) { + logger.error(`Invalid file path detected: ${ruleFile.relativePath}`) + continue // Skip this file but continue with others + } + + const targetPath = path.join(workspacePath, ".roo", normalizedRelativePath) + const normalizedTargetPath = path.normalize(targetPath) + const expectedBasePath = path.normalize(path.join(workspacePath, ".roo")) + + // Ensure the resolved path stays within the .roo directory + if (!normalizedTargetPath.startsWith(expectedBasePath)) { + logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`) + continue // Skip this file but continue with others + } + + // Ensure directory exists + const targetDir = path.dirname(targetPath) + await fs.mkdir(targetDir, { recursive: true }) + + // Write the file + await fs.writeFile(targetPath, ruleFile.content, "utf-8") + } + } + } + } else if (source === "global" && rulesFiles && Array.isArray(rulesFiles)) { + // For global imports, preserve the rules files structure in the global .roo directory + const globalRooDir = getGlobalRooDirectory() + + // Always remove the existing rules folder for this mode if it exists + // This ensures that if the imported mode has no rules, the folder is cleaned up + const rulesFolderPath = path.join(globalRooDir, `rules-${importMode.slug}`) + try { + await fs.rm(rulesFolderPath, { recursive: true, force: true }) + logger.info(`Removed existing global rules folder for mode ${importMode.slug}`) + } catch (error) { + // It's okay if the folder doesn't exist + logger.debug(`No existing global rules folder to remove for mode ${importMode.slug}`) + } + + // Import the new rules files with path validation + for (const ruleFile of rulesFiles) { + if (ruleFile.relativePath && ruleFile.content) { + // Validate the relative path to prevent path traversal attacks + const normalizedRelativePath = path.normalize(ruleFile.relativePath) + + // Ensure the path doesn't contain traversal sequences + if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) { + logger.error(`Invalid file path detected: ${ruleFile.relativePath}`) + continue // Skip this file but continue with others + } + + const targetPath = path.join(globalRooDir, normalizedRelativePath) + const normalizedTargetPath = path.normalize(targetPath) + const expectedBasePath = path.normalize(globalRooDir) + + // Ensure the resolved path stays within the global .roo directory + if (!normalizedTargetPath.startsWith(expectedBasePath)) { + logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`) + continue // Skip this file but continue with others + } + + // Ensure directory exists + const targetDir = path.dirname(targetPath) + await fs.mkdir(targetDir, { recursive: true }) + + // Write the file + await fs.writeFile(targetPath, ruleFile.content, "utf-8") + } + } + } + } + + // Refresh the modes after import + await this.refreshMergedState() + + return { success: true } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to import mode with rules", { error: errorMessage }) + return { success: false, error: errorMessage } + } + } + private clearCache(): void { this.cachedModes = null this.cachedAt = 0 diff --git a/src/core/config/__tests__/CustomModesManager.spec.ts b/src/core/config/__tests__/CustomModesManager.spec.ts index 2af801b646..c325a27f75 100644 --- a/src/core/config/__tests__/CustomModesManager.spec.ts +++ b/src/core/config/__tests__/CustomModesManager.spec.ts @@ -27,7 +27,14 @@ vi.mock("vscode", () => ({ }, })) -vi.mock("fs/promises") +vi.mock("fs/promises", () => ({ + mkdir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + stat: vi.fn(), + readdir: vi.fn(), + rm: vi.fn(), +})) vi.mock("../../../utils/fs") vi.mock("../../../utils/path") @@ -41,7 +48,8 @@ describe("CustomModesManager", () => { // Use path.sep to ensure correct path separators for the current platform const mockStoragePath = `${path.sep}mock${path.sep}settings` const mockSettingsPath = path.join(mockStoragePath, "settings", GlobalFileNames.customModes) - const mockRoomodes = `${path.sep}mock${path.sep}workspace${path.sep}.roomodes` + const mockWorkspacePath = path.resolve("/mock/workspace") + const mockRoomodes = path.join(mockWorkspacePath, ".roomodes") beforeEach(() => { mockOnUpdate = vi.fn() @@ -57,14 +65,19 @@ describe("CustomModesManager", () => { }, } as unknown as vscode.ExtensionContext - mockWorkspaceFolders = [{ uri: { fsPath: "/mock/workspace" } }] + // mockWorkspacePath is now defined at the top level + mockWorkspaceFolders = [{ uri: { fsPath: mockWorkspacePath } }] ;(vscode.workspace as any).workspaceFolders = mockWorkspaceFolders ;(vscode.workspace.onDidSaveTextDocument as Mock).mockReturnValue({ dispose: vi.fn() }) - ;(getWorkspacePath as Mock).mockReturnValue("/mock/workspace") + ;(getWorkspacePath as Mock).mockReturnValue(mockWorkspacePath) ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { return path === mockSettingsPath || path === mockRoomodes }) ;(fs.mkdir as Mock).mockResolvedValue(undefined) + ;(fs.writeFile as Mock).mockResolvedValue(undefined) + ;(fs.stat as Mock).mockResolvedValue({ isDirectory: () => true }) + ;(fs.readdir as Mock).mockResolvedValue([]) + ;(fs.rm as Mock).mockResolvedValue(undefined) ;(fs.readFile as Mock).mockImplementation(async (path: string) => { if (path === mockSettingsPath) { return yaml.stringify({ customModes: [] }) @@ -786,5 +799,777 @@ describe("CustomModesManager", () => { ], }) }) + + describe("importModeWithRules", () => { + it("should return error when YAML content is invalid", async () => { + const invalidYaml = "invalid yaml content" + + const result = await manager.importModeWithRules(invalidYaml) + + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid import format") + }) + + it("should return error when no custom modes found in YAML", async () => { + const emptyYaml = yaml.stringify({ customModes: [] }) + + const result = await manager.importModeWithRules(emptyYaml) + + expect(result.success).toBe(false) + expect(result.error).toBe("Invalid import format: Expected 'customModes' array in YAML") + }) + + it("should return error when no workspace is available", async () => { + ;(getWorkspacePath as Mock).mockReturnValue(null) + const validYaml = yaml.stringify({ + customModes: [ + { + slug: "test-mode", + name: "Test Mode", + roleDefinition: "Test Role", + groups: ["read"], + }, + ], + }) + + const result = await manager.importModeWithRules(validYaml) + + expect(result.success).toBe(false) + expect(result.error).toBe("No workspace found") + }) + + it("should successfully import mode without rules files", async () => { + const importYaml = yaml.stringify({ + customModes: [ + { + slug: "imported-mode", + name: "Imported Mode", + roleDefinition: "Imported Role", + groups: ["read", "edit"], + }, + ], + }) + + let roomodesContent: any = null + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + if (path === mockRoomodes && roomodesContent) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string) => { + if (path === mockRoomodes) { + roomodesContent = yaml.parse(content) + } + return Promise.resolve() + }) + + const result = await manager.importModeWithRules(importYaml) + + expect(result.success).toBe(true) + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining(".roomodes"), + expect.stringContaining("imported-mode"), + "utf-8", + ) + }) + + it("should successfully import mode with rules files", async () => { + const importYaml = yaml.stringify({ + customModes: [ + { + slug: "imported-mode", + name: "Imported Mode", + roleDefinition: "Imported Role", + groups: ["read"], + rulesFiles: [ + { + relativePath: "rules-imported-mode/rule1.md", + content: "Rule 1 content", + }, + { + relativePath: "rules-imported-mode/subfolder/rule2.md", + content: "Rule 2 content", + }, + ], + }, + ], + }) + + let roomodesContent: any = null + let writtenFiles: Record = {} + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + if (path === mockRoomodes && roomodesContent) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string) => { + if (path === mockRoomodes) { + roomodesContent = yaml.parse(content) + } else { + writtenFiles[path] = content + } + return Promise.resolve() + }) + ;(fs.mkdir as Mock).mockResolvedValue(undefined) + + const result = await manager.importModeWithRules(importYaml) + + expect(result.success).toBe(true) + + // Verify mode was imported + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining(".roomodes"), + expect.stringContaining("imported-mode"), + "utf-8", + ) + + // Verify rules files were created + expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining("rules-imported-mode"), { + recursive: true, + }) + expect(fs.mkdir).toHaveBeenCalledWith( + expect.stringContaining(path.join("rules-imported-mode", "subfolder")), + { recursive: true }, + ) + + // Verify file contents + const rule1Path = Object.keys(writtenFiles).find((p) => p.includes("rule1.md")) + const rule2Path = Object.keys(writtenFiles).find((p) => p.includes("rule2.md")) + expect(writtenFiles[rule1Path!]).toBe("Rule 1 content") + expect(writtenFiles[rule2Path!]).toBe("Rule 2 content") + }) + + it("should import multiple modes at once", async () => { + const importYaml = yaml.stringify({ + customModes: [ + { + slug: "mode1", + name: "Mode 1", + roleDefinition: "Role 1", + groups: ["read"], + }, + { + slug: "mode2", + name: "Mode 2", + roleDefinition: "Role 2", + groups: ["edit"], + rulesFiles: [ + { + relativePath: "rules-mode2/rule.md", + content: "Mode 2 rules", + }, + ], + }, + ], + }) + + let roomodesContent: any = null + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + if (path === mockRoomodes && roomodesContent) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string) => { + if (path === mockRoomodes) { + roomodesContent = yaml.parse(content) + } + return Promise.resolve() + }) + + const result = await manager.importModeWithRules(importYaml) + + expect(result.success).toBe(true) + expect(roomodesContent.customModes).toHaveLength(2) + expect(roomodesContent.customModes[0].slug).toBe("mode1") + expect(roomodesContent.customModes[1].slug).toBe("mode2") + }) + + it("should handle import errors gracefully", async () => { + const importYaml = yaml.stringify({ + customModes: [ + { + slug: "test-mode", + name: "Test Mode", + roleDefinition: "Test Role", + groups: ["read"], + rulesFiles: [ + { + relativePath: "rules-test-mode/rule.md", + content: "Rule content", + }, + ], + }, + ], + }) + + // Mock fs.readFile to work normally + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + if (path === mockRoomodes) { + throw new Error("File not found") + } + throw new Error("File not found") + }) + + // Mock fs.mkdir to fail when creating rules directory + ;(fs.mkdir as Mock).mockRejectedValue(new Error("Permission denied")) + + // Mock fs.writeFile to work normally for .roomodes but we won't get there + ;(fs.writeFile as Mock).mockResolvedValue(undefined) + + const result = await manager.importModeWithRules(importYaml) + + expect(result.success).toBe(false) + expect(result.error).toContain("Permission denied") + }) + + it("should prevent path traversal attacks in import", async () => { + const maliciousYaml = yaml.stringify({ + customModes: [ + { + slug: "test-mode", + name: "Test Mode", + roleDefinition: "Test Role", + groups: ["read"], + rulesFiles: [ + { + relativePath: "../../../etc/passwd", + content: "malicious content", + }, + { + relativePath: "rules-test-mode/../../../sensitive.txt", + content: "malicious content", + }, + { + relativePath: "/absolute/path/file.txt", + content: "malicious content", + }, + ], + }, + ], + }) + + let writtenFiles: string[] = [] + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string) => { + writtenFiles.push(path) + return Promise.resolve() + }) + ;(fs.mkdir as Mock).mockResolvedValue(undefined) + + const result = await manager.importModeWithRules(maliciousYaml) + + expect(result.success).toBe(true) + + // Verify that no files were written outside the .roo directory + const mockWorkspacePath = path.resolve("/mock/workspace") + const writtenRuleFiles = writtenFiles.filter((p) => !p.includes(".roomodes")) + writtenRuleFiles.forEach((filePath) => { + const normalizedPath = path.normalize(filePath) + const expectedBasePath = path.normalize(path.join(mockWorkspacePath, ".roo")) + expect(normalizedPath.startsWith(expectedBasePath)).toBe(true) + }) + + // Verify that malicious paths were not written + expect(writtenFiles.some((p) => p.includes("etc/passwd"))).toBe(false) + expect(writtenFiles.some((p) => p.includes("sensitive.txt"))).toBe(false) + expect(writtenFiles.some((p) => path.isAbsolute(p) && !p.startsWith(mockWorkspacePath))).toBe(false) + }) + + it("should handle malformed YAML gracefully", async () => { + const malformedYaml = ` + customModes: + - slug: test-mode + name: Test Mode + roleDefinition: Test Role + groups: [read + invalid yaml here + ` + + const result = await manager.importModeWithRules(malformedYaml) + + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid YAML format") + }) + + it("should validate mode configuration during import", async () => { + const invalidModeYaml = yaml.stringify({ + customModes: [ + { + slug: "test-mode", + name: "", // Invalid: empty name + roleDefinition: "", // Invalid: empty role definition + groups: ["invalid-group"], // Invalid group + }, + ], + }) + + const result = await manager.importModeWithRules(invalidModeYaml) + + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid mode configuration") + }) + + it("should remove existing rules folder when importing mode without rules", async () => { + const importYaml = yaml.stringify({ + customModes: [ + { + slug: "test-mode", + name: "Test Mode", + roleDefinition: "Test Role", + groups: ["read"], + // No rulesFiles property - this mode has no rules + }, + ], + }) + + let roomodesContent: any = null + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + if (path === mockRoomodes && roomodesContent) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string) => { + if (path === mockRoomodes) { + roomodesContent = yaml.parse(content) + } + return Promise.resolve() + }) + ;(fs.rm as Mock).mockResolvedValue(undefined) + + const result = await manager.importModeWithRules(importYaml) + + expect(result.success).toBe(true) + + // Verify that fs.rm was called to remove the existing rules folder + expect(fs.rm).toHaveBeenCalledWith(expect.stringContaining(path.join(".roo", "rules-test-mode")), { + recursive: true, + force: true, + }) + + // Verify mode was imported + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining(".roomodes"), + expect.stringContaining("test-mode"), + "utf-8", + ) + }) + + it("should remove existing rules folder and create new ones when importing mode with rules", async () => { + const importYaml = yaml.stringify({ + customModes: [ + { + slug: "test-mode", + name: "Test Mode", + roleDefinition: "Test Role", + groups: ["read"], + rulesFiles: [ + { + relativePath: "rules-test-mode/new-rule.md", + content: "New rule content", + }, + ], + }, + ], + }) + + let roomodesContent: any = null + let writtenFiles: Record = {} + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + if (path === mockRoomodes && roomodesContent) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string) => { + if (path === mockRoomodes) { + roomodesContent = yaml.parse(content) + } else { + writtenFiles[path] = content + } + return Promise.resolve() + }) + ;(fs.rm as Mock).mockResolvedValue(undefined) + ;(fs.mkdir as Mock).mockResolvedValue(undefined) + + const result = await manager.importModeWithRules(importYaml) + + expect(result.success).toBe(true) + + // Verify that fs.rm was called to remove the existing rules folder + expect(fs.rm).toHaveBeenCalledWith(expect.stringContaining(path.join(".roo", "rules-test-mode")), { + recursive: true, + force: true, + }) + + // Verify new rules files were created + expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining("rules-test-mode"), { recursive: true }) + + // Verify file contents + const newRulePath = Object.keys(writtenFiles).find((p) => p.includes("new-rule.md")) + expect(writtenFiles[newRulePath!]).toBe("New rule content") + }) + }) + }) + + describe("checkRulesDirectoryHasContent", () => { + it("should return false when no workspace is available", async () => { + ;(getWorkspacePath as Mock).mockReturnValue(null) + + const result = await manager.checkRulesDirectoryHasContent("test-mode") + + expect(result).toBe(false) + }) + + it("should return false when mode is not in .roomodes file", async () => { + const roomodesContent = { customModes: [{ slug: "other-mode", name: "Other Mode" }] } + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + + const result = await manager.checkRulesDirectoryHasContent("test-mode") + + expect(result).toBe(false) + }) + + it("should return false when .roomodes doesn't exist and mode is not a custom mode", async () => { + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockSettingsPath + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + throw new Error("File not found") + }) + + const result = await manager.checkRulesDirectoryHasContent("test-mode") + + expect(result).toBe(false) + }) + + it("should return false when rules directory doesn't exist", async () => { + const roomodesContent = { customModes: [{ slug: "test-mode", name: "Test Mode" }] } + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockRejectedValue(new Error("Directory not found")) + + const result = await manager.checkRulesDirectoryHasContent("test-mode") + + expect(result).toBe(false) + }) + + it("should return false when rules directory is empty", async () => { + const roomodesContent = { customModes: [{ slug: "test-mode", name: "Test Mode" }] } + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockResolvedValue({ isDirectory: () => true }) + ;(fs.readdir as Mock).mockResolvedValue([]) + + const result = await manager.checkRulesDirectoryHasContent("test-mode") + + expect(result).toBe(false) + }) + + it("should return true when rules directory has content files", async () => { + const roomodesContent = { customModes: [{ slug: "test-mode", name: "Test Mode" }] } + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + if (path.includes("rules-test-mode")) { + return "Some rule content" + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockResolvedValue({ isDirectory: () => true }) + ;(fs.readdir as Mock).mockResolvedValue([ + { name: "rule1.md", isFile: () => true, parentPath: "/mock/workspace/.roo/rules-test-mode" }, + ]) + + const result = await manager.checkRulesDirectoryHasContent("test-mode") + + expect(result).toBe(true) + }) + + it("should work with global custom modes when .roomodes doesn't exist", async () => { + const settingsContent = { + customModes: [{ slug: "test-mode", name: "Test Mode", groups: ["read"], roleDefinition: "Test Role" }], + } + + // Create a fresh manager instance to avoid cache issues + const freshManager = new CustomModesManager(mockContext, mockOnUpdate) + + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockSettingsPath // .roomodes doesn't exist + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify(settingsContent) + } + if (path.includes("rules-test-mode")) { + return "Some rule content" + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockResolvedValue({ isDirectory: () => true }) + ;(fs.readdir as Mock).mockResolvedValue([ + { name: "rule1.md", isFile: () => true, parentPath: "/mock/workspace/.roo/rules-test-mode" }, + ]) + + const result = await freshManager.checkRulesDirectoryHasContent("test-mode") + + expect(result).toBe(true) + }) + }) + + describe("exportModeWithRules", () => { + it("should return error when no workspace is available", async () => { + // Create a fresh manager instance to avoid cache issues + const freshManager = new CustomModesManager(mockContext, mockOnUpdate) + + // Mock no workspace folders + ;(vscode.workspace as any).workspaceFolders = [] + ;(getWorkspacePath as Mock).mockReturnValue(null) + ;(fileExistsAtPath as Mock).mockResolvedValue(false) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + throw new Error("File not found") + }) + + const result = await freshManager.exportModeWithRules("test-mode") + + expect(result.success).toBe(false) + expect(result.error).toBe("No workspace found") + }) + + it("should return error when mode is not found", async () => { + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + throw new Error("File not found") + }) + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockSettingsPath + }) + + const result = await manager.exportModeWithRules("test-mode") + + expect(result.success).toBe(false) + expect(result.error).toBe("Mode not found") + }) + + it("should successfully export mode without rules when rules directory doesn't exist", async () => { + const roomodesContent = { + customModes: [{ slug: "test-mode", name: "Test Mode", roleDefinition: "Test Role", groups: ["read"] }], + } + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockRejectedValue(new Error("Directory not found")) + + const result = await manager.exportModeWithRules("test-mode") + + expect(result.success).toBe(true) + expect(result.yaml).toContain("test-mode") + expect(result.yaml).toContain("Test Mode") + }) + + it("should successfully export mode without rules when no rule files are found", async () => { + const roomodesContent = { + customModes: [{ slug: "test-mode", name: "Test Mode", roleDefinition: "Test Role", groups: ["read"] }], + } + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockResolvedValue({ isDirectory: () => true }) + ;(fs.readdir as Mock).mockResolvedValue([]) + + const result = await manager.exportModeWithRules("test-mode") + + expect(result.success).toBe(true) + expect(result.yaml).toContain("test-mode") + }) + + it("should successfully export mode with rules for a custom mode in .roomodes", async () => { + const roomodesContent = { + customModes: [ + { + slug: "test-mode", + name: "Test Mode", + roleDefinition: "Test Role", + groups: ["read"], + customInstructions: "Existing instructions", + }, + ], + } + + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + if (path.includes("rules-test-mode")) { + return "New rule content from files" + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockResolvedValue({ isDirectory: () => true }) + ;(fs.readdir as Mock).mockResolvedValue([ + { name: "rule1.md", isFile: () => true, parentPath: "/mock/workspace/.roo/rules-test-mode" }, + ]) + + const result = await manager.exportModeWithRules("test-mode") + + expect(result.success).toBe(true) + expect(result.yaml).toContain("test-mode") + expect(result.yaml).toContain("Existing instructions") + expect(result.yaml).toContain("New rule content from files") + // Should NOT delete the rules directory + expect(fs.rm).not.toHaveBeenCalled() + }) + + it("should successfully export mode with rules for a built-in mode customized in .roomodes", async () => { + const roomodesContent = { + customModes: [ + { + slug: "code", + name: "Custom Code Mode", + roleDefinition: "Custom Role", + groups: ["read"], + }, + ], + } + + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + if (path.includes("rules-code")) { + return "Custom rules for code mode" + } + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockResolvedValue({ isDirectory: () => true }) + ;(fs.readdir as Mock).mockResolvedValue([ + { name: "rule1.md", isFile: () => true, parentPath: "/mock/workspace/.roo/rules-code" }, + ]) + + const result = await manager.exportModeWithRules("code") + + expect(result.success).toBe(true) + expect(result.yaml).toContain("Custom Code Mode") + expect(result.yaml).toContain("Custom rules for code mode") + // Should NOT delete the rules directory + expect(fs.rm).not.toHaveBeenCalled() + }) + + it("should handle file read errors gracefully", async () => { + const roomodesContent = { + customModes: [ + { + slug: "test-mode", + name: "Test Mode", + roleDefinition: "Test Role", + groups: ["read"], + }, + ], + } + + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockRoomodes + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockRoomodes) { + return yaml.stringify(roomodesContent) + } + if (path.includes("rules-test-mode")) { + throw new Error("Permission denied") + } + throw new Error("File not found") + }) + ;(fs.stat as Mock).mockResolvedValue({ isDirectory: () => true }) + ;(fs.readdir as Mock).mockResolvedValue([ + { name: "rule1.md", isFile: () => true, parentPath: "/mock/workspace/.roo/rules-test-mode" }, + ]) + + const result = await manager.exportModeWithRules("test-mode") + + // Should still succeed even if file read fails + expect(result.success).toBe(true) + expect(result.yaml).toContain("test-mode") + }) }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index cac94aa0ce..b844b4ef46 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1,8 +1,9 @@ import { safeWriteJson } from "../../utils/safeWriteJson" import * as path from "path" -import fs from "fs/promises" +import * as fs from "fs/promises" import pWaitFor from "p-wait-for" import * as vscode from "vscode" +import * as yaml from "yaml" import { type Language, type ProviderSettings, type GlobalState, TelemetryEventName } from "@roo-code/types" import { CloudService } from "@roo-code/cloud" @@ -1501,6 +1502,196 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() } break + case "exportMode": + if (message.slug) { + try { + // Get custom mode prompts to check if built-in mode has been customized + const customModePrompts = getGlobalState("customModePrompts") || {} + const customPrompt = customModePrompts[message.slug] + + // Export the mode with any customizations merged directly + const result = await provider.customModesManager.exportModeWithRules(message.slug, customPrompt) + + if (result.success && result.yaml) { + // Get last used directory for export + const lastExportPath = getGlobalState("lastModeExportPath") + let defaultUri: vscode.Uri + + if (lastExportPath) { + // Use the directory from the last export + const lastDir = path.dirname(lastExportPath) + defaultUri = vscode.Uri.file(path.join(lastDir, `${message.slug}-export.yaml`)) + } else { + // Default to workspace or home directory + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders && workspaceFolders.length > 0) { + defaultUri = vscode.Uri.file( + path.join(workspaceFolders[0].uri.fsPath, `${message.slug}-export.yaml`), + ) + } else { + defaultUri = vscode.Uri.file(`${message.slug}-export.yaml`) + } + } + + // Show save dialog + const saveUri = await vscode.window.showSaveDialog({ + defaultUri, + filters: { + "YAML files": ["yaml", "yml"], + }, + title: "Save mode export", + }) + + if (saveUri && result.yaml) { + // Save the directory for next time + await updateGlobalState("lastModeExportPath", saveUri.fsPath) + + // Write the file to the selected location + await fs.writeFile(saveUri.fsPath, result.yaml, "utf-8") + + // Send success message to webview + provider.postMessageToWebview({ + type: "exportModeResult", + success: true, + slug: message.slug, + }) + + // Show info message + vscode.window.showInformationMessage(t("common:info.mode_exported", { mode: message.slug })) + } else { + // User cancelled the save dialog + provider.postMessageToWebview({ + type: "exportModeResult", + success: false, + error: "Export cancelled", + slug: message.slug, + }) + } + } else { + // Send error message to webview + provider.postMessageToWebview({ + type: "exportModeResult", + success: false, + error: result.error, + slug: message.slug, + }) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Failed to export mode ${message.slug}: ${errorMessage}`) + + // Send error message to webview + provider.postMessageToWebview({ + type: "exportModeResult", + success: false, + error: errorMessage, + slug: message.slug, + }) + } + } + break + case "importMode": + try { + // Get last used directory for import + const lastImportPath = getGlobalState("lastModeImportPath") + let defaultUri: vscode.Uri | undefined + + if (lastImportPath) { + // Use the directory from the last import + const lastDir = path.dirname(lastImportPath) + defaultUri = vscode.Uri.file(lastDir) + } else { + // Default to workspace or home directory + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders && workspaceFolders.length > 0) { + defaultUri = vscode.Uri.file(workspaceFolders[0].uri.fsPath) + } + } + + // Show file picker to select YAML file + const fileUri = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + defaultUri, + filters: { + "YAML files": ["yaml", "yml"], + }, + title: "Select mode export file to import", + }) + + if (fileUri && fileUri[0]) { + // Save the directory for next time + await updateGlobalState("lastModeImportPath", fileUri[0].fsPath) + + // Read the file content + const yamlContent = await fs.readFile(fileUri[0].fsPath, "utf-8") + + // Import the mode with the specified source level + const result = await provider.customModesManager.importModeWithRules( + yamlContent, + message.source || "project", // Default to project if not specified + ) + + if (result.success) { + // Update state after importing + const customModes = await provider.customModesManager.getCustomModes() + await updateGlobalState("customModes", customModes) + await provider.postStateToWebview() + + // Send success message to webview + provider.postMessageToWebview({ + type: "importModeResult", + success: true, + }) + + // Show success message + vscode.window.showInformationMessage(t("common:info.mode_imported")) + } else { + // Send error message to webview + provider.postMessageToWebview({ + type: "importModeResult", + success: false, + error: result.error, + }) + + // Show error message + vscode.window.showErrorMessage(t("common:errors.mode_import_failed", { error: result.error })) + } + } else { + // User cancelled the file dialog - reset the importing state + provider.postMessageToWebview({ + type: "importModeResult", + success: false, + error: "cancelled", + }) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Failed to import mode: ${errorMessage}`) + + // Send error message to webview + provider.postMessageToWebview({ + type: "importModeResult", + success: false, + error: errorMessage, + }) + + // Show error message + vscode.window.showErrorMessage(t("common:errors.mode_import_failed", { error: errorMessage })) + } + break + case "checkRulesDirectory": + if (message.slug) { + const hasContent = await provider.customModesManager.checkRulesDirectoryHasContent(message.slug) + + provider.postMessageToWebview({ + type: "checkRulesDirectoryResult", + slug: message.slug, + hasContent: hasContent, + }) + } + break case "humanRelayResponse": if (message.requestId && message.text) { vscode.commands.executeCommand(getCommand("handleHumanRelayResponse"), { diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 8e9f07c100..e20d7a155a 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -73,7 +73,8 @@ "processExitedWithError": "El procés Claude Code ha sortit amb codi {{exitCode}}. Sortida d'error: {{output}}", "stoppedWithReason": "Claude Code s'ha aturat per la raó: {{reason}}", "apiKeyModelPlanMismatch": "Les claus API i els plans de subscripció permeten models diferents. Assegura't que el model seleccionat estigui inclòs al teu pla." - } + }, + "mode_import_failed": "Ha fallat la importació del mode: {{error}}" }, "warnings": { "no_terminal_content": "No s'ha seleccionat contingut de terminal", @@ -90,7 +91,9 @@ "image_copied_to_clipboard": "URI de dades de la imatge copiada al portapapers", "image_saved": "Imatge desada a {{path}}", "organization_share_link_copied": "Enllaç de compartició d'organització copiat al porta-retalls!", - "public_share_link_copied": "Enllaç de compartició pública copiat al porta-retalls!" + "public_share_link_copied": "Enllaç de compartició pública copiat al porta-retalls!", + "mode_exported": "Mode '{{mode}}' exportat correctament", + "mode_imported": "Mode importat correctament" }, "answers": { "yes": "Sí", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index fc4ce25de1..d9e8fcd22c 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -63,6 +63,7 @@ "share_auth_required": "Authentifizierung erforderlich. Bitte melde dich an, um Aufgaben zu teilen.", "share_not_enabled": "Aufgabenfreigabe ist für diese Organisation nicht aktiviert.", "share_task_not_found": "Aufgabe nicht gefunden oder Zugriff verweigert.", + "mode_import_failed": "Fehler beim Importieren des Modus: {{error}}", "claudeCode": { "processExited": "Claude Code Prozess wurde mit Code {{exitCode}} beendet.", "errorOutput": "Fehlerausgabe: {{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "Bild-Daten-URI in die Zwischenablage kopiert", "image_saved": "Bild gespeichert unter {{path}}", "organization_share_link_copied": "Organisations-Freigabelink in die Zwischenablage kopiert!", - "public_share_link_copied": "Öffentlicher Freigabelink in die Zwischenablage kopiert!" + "public_share_link_copied": "Öffentlicher Freigabelink in die Zwischenablage kopiert!", + "mode_exported": "Modus '{{mode}}' erfolgreich exportiert", + "mode_imported": "Modus erfolgreich importiert" }, "answers": { "yes": "Ja", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 3f19a1dd50..fa3c7bb1c1 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -63,6 +63,7 @@ "share_auth_required": "Authentication required. Please sign in to share tasks.", "share_not_enabled": "Task sharing is not enabled for this organization.", "share_task_not_found": "Task not found or access denied.", + "mode_import_failed": "Failed to import mode: {{error}}", "claudeCode": { "processExited": "Claude Code process exited with code {{exitCode}}.", "errorOutput": "Error output: {{output}}", @@ -86,7 +87,9 @@ "organization_share_link_copied": "Organization share link copied to clipboard!", "public_share_link_copied": "Public share link copied to clipboard!", "image_copied_to_clipboard": "Image data URI copied to clipboard", - "image_saved": "Image saved to {{path}}" + "image_saved": "Image saved to {{path}}", + "mode_exported": "Mode '{{mode}}' exported successfully", + "mode_imported": "Mode imported successfully" }, "answers": { "yes": "Yes", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 4b3177619e..406c5ac0ff 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -63,6 +63,7 @@ "share_auth_required": "Se requiere autenticación. Por favor, inicia sesión para compartir tareas.", "share_not_enabled": "La compartición de tareas no está habilitada para esta organización.", "share_task_not_found": "Tarea no encontrada o acceso denegado.", + "mode_import_failed": "Error al importar el modo: {{error}}", "claudeCode": { "processExited": "El proceso de Claude Code terminó con código {{exitCode}}.", "errorOutput": "Salida de error: {{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "URI de datos de imagen copiada al portapapeles", "image_saved": "Imagen guardada en {{path}}", "organization_share_link_copied": "¡Enlace de compartición de organización copiado al portapapeles!", - "public_share_link_copied": "¡Enlace de compartición pública copiado al portapapeles!" + "public_share_link_copied": "¡Enlace de compartición pública copiado al portapapeles!", + "mode_exported": "Modo '{{mode}}' exportado correctamente", + "mode_imported": "Modo importado correctamente" }, "answers": { "yes": "Sí", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 93cd67ca15..bf159107b8 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -63,6 +63,7 @@ "share_auth_required": "Authentification requise. Veuillez vous connecter pour partager des tâches.", "share_not_enabled": "Le partage de tâches n'est pas activé pour cette organisation.", "share_task_not_found": "Tâche non trouvée ou accès refusé.", + "mode_import_failed": "Échec de l'importation du mode : {{error}}", "claudeCode": { "processExited": "Le processus Claude Code s'est terminé avec le code {{exitCode}}.", "errorOutput": "Sortie d'erreur : {{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "URI de données d'image copiée dans le presse-papiers", "image_saved": "Image enregistrée dans {{path}}", "organization_share_link_copied": "Lien de partage d'organisation copié dans le presse-papiers !", - "public_share_link_copied": "Lien de partage public copié dans le presse-papiers !" + "public_share_link_copied": "Lien de partage public copié dans le presse-papiers !", + "mode_exported": "Mode '{{mode}}' exporté avec succès", + "mode_imported": "Mode importé avec succès" }, "answers": { "yes": "Oui", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 68788beacc..aea9210df5 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -63,6 +63,7 @@ "share_auth_required": "प्रमाणीकरण आवश्यक है। कार्य साझा करने के लिए कृपया साइन इन करें।", "share_not_enabled": "इस संगठन के लिए कार्य साझाकरण सक्षम नहीं है।", "share_task_not_found": "कार्य नहीं मिला या पहुंच अस्वीकृत।", + "mode_import_failed": "मोड आयात करने में विफल: {{error}}", "claudeCode": { "processExited": "Claude Code प्रक्रिया कोड {{exitCode}} के साथ समाप्त हुई।", "errorOutput": "त्रुटि आउटपुट: {{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "छवि डेटा URI क्लिपबोर्ड में कॉपी की गई", "image_saved": "छवि {{path}} में सहेजी गई", "organization_share_link_copied": "संगठन साझाकरण लिंक क्लिपबोर्ड में कॉपी किया गया!", - "public_share_link_copied": "सार्वजनिक साझाकरण लिंक क्लिपबोर्ड में कॉपी किया गया!" + "public_share_link_copied": "सार्वजनिक साझाकरण लिंक क्लिपबोर्ड में कॉपी किया गया!", + "mode_exported": "मोड '{{mode}}' सफलतापूर्वक निर्यात किया गया", + "mode_imported": "मोड सफलतापूर्वक आयात किया गया" }, "answers": { "yes": "हां", @@ -117,7 +120,7 @@ "getGroqApiKey": "ग्रोक एपीआई कुंजी प्राप्त करें", "claudeCode": { "pathLabel": "क्लाउड कोड पाथ", - "description": "आपके क्लाउड कोड CLI का वैकल्पिक पाथ। सेट न होने पर डिफ़ॉल्ट रूप से 'claude'。", + "description": "आपके क्लाउड कोड CLI का वैकल्पिक पाथ। सेट न होने पर डिफ़ॉल्ट रूप से 'claude'।", "placeholder": "डिफ़ॉल्ट: claude" } } diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index d6bb2ffa98..c156a22a1f 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -63,6 +63,7 @@ "share_auth_required": "Autentikasi diperlukan. Silakan masuk untuk berbagi tugas.", "share_not_enabled": "Berbagi tugas tidak diaktifkan untuk organisasi ini.", "share_task_not_found": "Tugas tidak ditemukan atau akses ditolak.", + "mode_import_failed": "Gagal mengimpor mode: {{error}}", "claudeCode": { "processExited": "Proses Claude Code keluar dengan kode {{exitCode}}.", "errorOutput": "Output error: {{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "Data URI gambar disalin ke clipboard", "image_saved": "Gambar disimpan ke {{path}}", "organization_share_link_copied": "Tautan berbagi organisasi disalin ke clipboard!", - "public_share_link_copied": "Tautan berbagi publik disalin ke clipboard!" + "public_share_link_copied": "Tautan berbagi publik disalin ke clipboard!", + "mode_exported": "Mode '{{mode}}' berhasil diekspor", + "mode_imported": "Mode berhasil diimpor" }, "answers": { "yes": "Ya", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 0ef6ee5b54..69fb530ebf 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -63,6 +63,7 @@ "share_auth_required": "Autenticazione richiesta. Accedi per condividere le attività.", "share_not_enabled": "La condivisione delle attività non è abilitata per questa organizzazione.", "share_task_not_found": "Attività non trovata o accesso negato.", + "mode_import_failed": "Importazione della modalità non riuscita: {{error}}", "claudeCode": { "processExited": "Il processo Claude Code è terminato con codice {{exitCode}}.", "errorOutput": "Output di errore: {{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "URI dati dell'immagine copiato negli appunti", "image_saved": "Immagine salvata in {{path}}", "organization_share_link_copied": "Link di condivisione organizzazione copiato negli appunti!", - "public_share_link_copied": "Link di condivisione pubblica copiato negli appunti!" + "public_share_link_copied": "Link di condivisione pubblica copiato negli appunti!", + "mode_exported": "Modalità '{{mode}}' esportata con successo", + "mode_imported": "Modalità importata con successo" }, "answers": { "yes": "Sì", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index b132470eac..f6204a1c43 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -63,6 +63,7 @@ "share_auth_required": "認証が必要です。タスクを共有するにはサインインしてください。", "share_not_enabled": "この組織ではタスク共有が有効になっていません。", "share_task_not_found": "タスクが見つからないか、アクセスが拒否されました。", + "mode_import_failed": "モードのインポートに失敗しました: {{error}}", "claudeCode": { "processExited": "Claude Code プロセスがコード {{exitCode}} で終了しました。", "errorOutput": "エラー出力:{{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "画像データURIがクリップボードにコピーされました", "image_saved": "画像を{{path}}に保存しました", "organization_share_link_copied": "組織共有リンクがクリップボードにコピーされました!", - "public_share_link_copied": "公開共有リンクがクリップボードにコピーされました!" + "public_share_link_copied": "公開共有リンクがクリップボードにコピーされました!", + "mode_exported": "モード「{{mode}}」が正常にエクスポートされました", + "mode_imported": "モードが正常にインポートされました" }, "answers": { "yes": "はい", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 079bb56a48..9ea04aa67e 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -63,6 +63,7 @@ "share_auth_required": "인증이 필요합니다. 작업을 공유하려면 로그인하세요.", "share_not_enabled": "이 조직에서는 작업 공유가 활성화되지 않았습니다.", "share_task_not_found": "작업을 찾을 수 없거나 액세스가 거부되었습니다.", + "mode_import_failed": "모드 가져오기 실패: {{error}}", "claudeCode": { "processExited": "Claude Code 프로세스가 코드 {{exitCode}}로 종료되었습니다.", "errorOutput": "오류 출력: {{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "이미지 데이터 URI가 클립보드에 복사되었습니다", "image_saved": "이미지가 {{path}}에 저장되었습니다", "organization_share_link_copied": "조직 공유 링크가 클립보드에 복사되었습니다!", - "public_share_link_copied": "공개 공유 링크가 클립보드에 복사되었습니다!" + "public_share_link_copied": "공개 공유 링크가 클립보드에 복사되었습니다!", + "mode_exported": "'{{mode}}' 모드가 성공적으로 내보내졌습니다", + "mode_imported": "모드를 성공적으로 가져왔습니다" }, "answers": { "yes": "예", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index ef27bef3d8..4e3294e3f0 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -63,6 +63,7 @@ "share_auth_required": "Authenticatie vereist. Log in om taken te delen.", "share_not_enabled": "Taken delen is niet ingeschakeld voor deze organisatie.", "share_task_not_found": "Taak niet gevonden of toegang geweigerd.", + "mode_import_failed": "Importeren van modus mislukt: {{error}}", "claudeCode": { "processExited": "Claude Code proces beëindigd met code {{exitCode}}.", "errorOutput": "Foutuitvoer: {{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "Afbeelding data-URI gekopieerd naar klembord", "image_saved": "Afbeelding opgeslagen naar {{path}}", "organization_share_link_copied": "Organisatie deel-link gekopieerd naar klembord!", - "public_share_link_copied": "Openbare deel-link gekopieerd naar klembord!" + "public_share_link_copied": "Openbare deel-link gekopieerd naar klembord!", + "mode_exported": "Modus '{{mode}}' succesvol geëxporteerd", + "mode_imported": "Modus succesvol geïmporteerd" }, "answers": { "yes": "Ja", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 777bbd82b7..3a650d5145 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -63,6 +63,7 @@ "share_auth_required": "Wymagana autoryzacja. Zaloguj się, aby udostępniać zadania.", "share_not_enabled": "Udostępnianie zadań nie jest włączone dla tej organizacji.", "share_task_not_found": "Zadanie nie znalezione lub dostęp odmówiony.", + "mode_import_failed": "Import trybu nie powiódł się: {{error}}", "claudeCode": { "processExited": "Proces Claude Code zakończył się kodem {{exitCode}}.", "errorOutput": "Wyjście błędu: {{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "URI danych obrazu skopiowane do schowka", "image_saved": "Obraz zapisany w {{path}}", "organization_share_link_copied": "Link udostępniania organizacji skopiowany do schowka!", - "public_share_link_copied": "Publiczny link udostępniania skopiowany do schowka!" + "public_share_link_copied": "Publiczny link udostępniania skopiowany do schowka!", + "mode_exported": "Tryb '{{mode}}' pomyślnie wyeksportowany", + "mode_imported": "Tryb pomyślnie zaimportowany" }, "answers": { "yes": "Tak", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 18695588c8..d3a7dcdbc5 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -67,6 +67,7 @@ "share_auth_required": "Autenticação necessária. Faça login para compartilhar tarefas.", "share_not_enabled": "O compartilhamento de tarefas não está habilitado para esta organização.", "share_task_not_found": "Tarefa não encontrada ou acesso negado.", + "mode_import_failed": "Falha ao importar o modo: {{error}}", "claudeCode": { "processExited": "O processo Claude Code saiu com código {{exitCode}}.", "errorOutput": "Saída de erro: {{output}}", @@ -90,7 +91,9 @@ "image_copied_to_clipboard": "URI de dados da imagem copiada para a área de transferência", "image_saved": "Imagem salva em {{path}}", "organization_share_link_copied": "Link de compartilhamento da organização copiado para a área de transferência!", - "public_share_link_copied": "Link de compartilhamento público copiado para a área de transferência!" + "public_share_link_copied": "Link de compartilhamento público copiado para a área de transferência!", + "mode_exported": "Modo '{{mode}}' exportado com sucesso", + "mode_imported": "Modo importado com sucesso" }, "answers": { "yes": "Sim", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index b0e4f58ceb..7559e90cd3 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -63,6 +63,7 @@ "share_auth_required": "Требуется аутентификация. Войдите в систему для совместного доступа к задачам.", "share_not_enabled": "Совместный доступ к задачам не включен для этой организации.", "share_task_not_found": "Задача не найдена или доступ запрещен.", + "mode_import_failed": "Не удалось импортировать режим: {{error}}", "claudeCode": { "processExited": "Процесс Claude Code завершился с кодом {{exitCode}}.", "errorOutput": "Вывод ошибки: {{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "URI данных изображения скопирован в буфер обмена", "image_saved": "Изображение сохранено в {{path}}", "organization_share_link_copied": "Ссылка для совместного доступа организации скопирована в буфер обмена!", - "public_share_link_copied": "Публичная ссылка для совместного доступа скопирована в буфер обмена!" + "public_share_link_copied": "Публичная ссылка для совместного доступа скопирована в буфер обмена!", + "mode_exported": "Режим '{{mode}}' успешно экспортирован", + "mode_imported": "Режим успешно импортирован" }, "answers": { "yes": "Да", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 9663cac808..041e77fdc8 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -63,6 +63,7 @@ "share_auth_required": "Kimlik doğrulama gerekli. Görevleri paylaşmak için lütfen giriş yapın.", "share_not_enabled": "Bu kuruluş için görev paylaşımı etkinleştirilmemiş.", "share_task_not_found": "Görev bulunamadı veya erişim reddedildi.", + "mode_import_failed": "Mod içe aktarılamadı: {{error}}", "claudeCode": { "processExited": "Claude Code işlemi {{exitCode}} koduyla çıktı.", "errorOutput": "Hata çıktısı: {{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "Resim veri URI'si panoya kopyalandı", "image_saved": "Resim {{path}} konumuna kaydedildi", "organization_share_link_copied": "Kuruluş paylaşım bağlantısı panoya kopyalandı!", - "public_share_link_copied": "Herkese açık paylaşım bağlantısı panoya kopyalandı!" + "public_share_link_copied": "Herkese açık paylaşım bağlantısı panoya kopyalandı!", + "mode_exported": "'{{mode}}' modu başarıyla dışa aktarıldı", + "mode_imported": "Mod başarıyla içe aktarıldı" }, "answers": { "yes": "Evet", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 5a9d0983f8..08a4238e8b 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -63,6 +63,7 @@ "share_auth_required": "Cần xác thực. Vui lòng đăng nhập để chia sẻ nhiệm vụ.", "share_not_enabled": "Chia sẻ nhiệm vụ không được bật cho tổ chức này.", "share_task_not_found": "Không tìm thấy nhiệm vụ hoặc truy cập bị từ chối.", + "mode_import_failed": "Nhập chế độ thất bại: {{error}}", "claudeCode": { "processExited": "Tiến trình Claude Code thoát với mã {{exitCode}}.", "errorOutput": "Đầu ra lỗi: {{output}}", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "URI dữ liệu hình ảnh đã được sao chép vào clipboard", "image_saved": "Hình ảnh đã được lưu vào {{path}}", "organization_share_link_copied": "Liên kết chia sẻ tổ chức đã được sao chép vào clipboard!", - "public_share_link_copied": "Liên kết chia sẻ công khai đã được sao chép vào clipboard!" + "public_share_link_copied": "Liên kết chia sẻ công khai đã được sao chép vào clipboard!", + "mode_exported": "Chế độ '{{mode}}' đã được xuất thành công", + "mode_imported": "Chế độ đã được nhập thành công" }, "answers": { "yes": "Có", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index b355c2ec35..de702c64dc 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -68,6 +68,7 @@ "share_auth_required": "需要身份验证。请登录以分享任务。", "share_not_enabled": "此组织未启用任务分享功能。", "share_task_not_found": "未找到任务或访问被拒绝。", + "mode_import_failed": "导入模式失败:{{error}}", "claudeCode": { "processExited": "Claude Code 进程退出,退出码:{{exitCode}}。", "errorOutput": "错误输出:{{output}}", @@ -91,7 +92,9 @@ "image_copied_to_clipboard": "图片数据 URI 已复制到剪贴板", "image_saved": "图片已保存到 {{path}}", "organization_share_link_copied": "组织分享链接已复制到剪贴板!", - "public_share_link_copied": "公开分享链接已复制到剪贴板!" + "public_share_link_copied": "公开分享链接已复制到剪贴板!", + "mode_exported": "模式 '{{mode}}' 已成功导出", + "mode_imported": "模式已成功导入" }, "answers": { "yes": "是", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 6c1c9747aa..fd5c0cae13 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -69,7 +69,8 @@ "processExitedWithError": "Claude Code 程序退出,退出碼:{{exitCode}}。錯誤輸出:{{output}}", "stoppedWithReason": "Claude Code 停止,原因:{{reason}}", "apiKeyModelPlanMismatch": "API 金鑰和訂閱方案允許不同的模型。請確保所選模型包含在您的方案中。" - } + }, + "mode_import_failed": "匯入模式失敗:{{error}}" }, "warnings": { "no_terminal_content": "沒有選擇終端機內容", @@ -86,7 +87,9 @@ "image_copied_to_clipboard": "圖片資料 URI 已複製到剪貼簿", "image_saved": "圖片已儲存至 {{path}}", "organization_share_link_copied": "組織分享連結已複製到剪貼簿!", - "public_share_link_copied": "公開分享連結已複製到剪貼簿!" + "public_share_link_copied": "公開分享連結已複製到剪貼簿!", + "mode_exported": "模式 '{{mode}}' 已成功匯出", + "mode_imported": "模式已成功匯入" }, "answers": { "yes": "是", diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 73ebf59d4c..031c1f1d6f 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -73,6 +73,9 @@ export interface ExtensionMessage { | "autoApprovalEnabled" | "updateCustomMode" | "deleteCustomMode" + | "exportModeResult" + | "importModeResult" + | "checkRulesDirectoryResult" | "currentCheckpointUpdated" | "showHumanRelayDialog" | "humanRelayResponse" @@ -141,6 +144,7 @@ export interface ExtensionMessage { error?: string setting?: string value?: any + hasContent?: boolean // For checkRulesDirectoryResult items?: MarketplaceItem[] userInfo?: CloudUserInfo organizationAllowList?: OrganizationAllowList diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 7efc97e8c7..07f2b7eedf 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -175,6 +175,12 @@ export interface WebviewMessage { | "switchTab" | "profileThresholds" | "shareTaskSuccess" + | "exportMode" + | "exportModeResult" + | "importMode" + | "importModeResult" + | "checkRulesDirectory" + | "checkRulesDirectoryResult" text?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" disabled?: boolean @@ -213,6 +219,7 @@ export interface WebviewMessage { mpInstallOptions?: InstallMarketplaceItemOptions config?: Record // Add config to the payload visibility?: ShareVisibility // For share visibility + hasContent?: boolean // For checkRulesDirectoryResult } export const checkoutDiffPayloadSchema = z.object({ diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index 2dd6c6dc76..2da6669012 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -8,7 +8,7 @@ import { VSCodeTextField, } from "@vscode/webview-ui-toolkit/react" import { Trans } from "react-i18next" -import { ChevronDown, X } from "lucide-react" +import { ChevronDown, X, Upload, Download } from "lucide-react" import { ModeConfig, GroupEntry, PromptComponent, ToolGroup, modeConfigSchema } from "@roo-code/types" @@ -92,6 +92,10 @@ const ModesView = ({ onDone }: ModesViewProps) => { const [showConfigMenu, setShowConfigMenu] = useState(false) const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false) const [isSystemPromptDisclosureOpen, setIsSystemPromptDisclosureOpen] = useState(false) + const [isExporting, setIsExporting] = useState(false) + const [isImporting, setIsImporting] = useState(false) + const [showImportDialog, setShowImportDialog] = useState(false) + const [hasRulesToExport, setHasRulesToExport] = useState>({}) // State for mode selection popover and search const [open, setOpen] = useState(false) @@ -190,6 +194,22 @@ const ModesView = ({ onDone }: ModesViewProps) => { return customModes?.find(findMode) || modes.find(findMode) }, [visualMode, customModes, modes]) + // Check if the current mode has rules to export + const checkRulesDirectory = useCallback((slug: string) => { + vscode.postMessage({ + type: "checkRulesDirectory", + slug: slug, + }) + }, []) + + // Check rules directory when mode changes + useEffect(() => { + const currentMode = getCurrentMode() + if (currentMode?.slug && hasRulesToExport[currentMode.slug] === undefined) { + checkRulesDirectory(currentMode.slug) + } + }, [getCurrentMode, checkRulesDirectory, hasRulesToExport]) + // Helper function to safely access mode properties const getModeProperty = ( mode: ModeConfig | undefined, @@ -397,6 +417,28 @@ const ModesView = ({ onDone }: ModesViewProps) => { setSelectedPromptTitle(`System Prompt (${message.mode} mode)`) setIsDialogOpen(true) } + } else if (message.type === "exportModeResult") { + setIsExporting(false) + + if (!message.success) { + // Show error message + console.error("Failed to export mode:", message.error) + } + } else if (message.type === "importModeResult") { + setIsImporting(false) + setShowImportDialog(false) + + if (!message.success) { + // Only log error if it's not a cancellation + if (message.error !== "cancelled") { + console.error("Failed to import mode:", message.error) + } + } + } else if (message.type === "checkRulesDirectoryResult") { + setHasRulesToExport((prev) => ({ + ...prev, + [message.slug]: message.hasContent, + })) } } @@ -1033,7 +1075,7 @@ const ModesView = ({ onDone }: ModesViewProps) => {
-
+
- {/* Custom System Prompt Disclosure */} + {/* Export/Import Mode Buttons */} +
+ {/* Export button - visible when any mode is selected */} + {getCurrentMode() && ( + + )} + {/* Import button - always visible */} + +
+ + {/* Advanced Features Disclosure */}
{isSystemPromptDisclosureOpen && ( -
- { - const currentMode = getCurrentMode() - if (!currentMode) return - - vscode.postMessage({ - type: "openFile", - text: `./.roo/system-prompt-${currentMode.slug}`, - values: { - create: true, - content: "", - }, - }) - }} - /> - ), - "1": ( - - ), - "2": , - }} - /> +
+ {/* Override System Prompt Section */} +
+

+ Override System Prompt +

+
+ { + const currentMode = getCurrentMode() + if (!currentMode) return + + vscode.postMessage({ + type: "openFile", + text: `./.roo/system-prompt-${currentMode.slug}`, + values: { + create: true, + content: "", + }, + }) + }} + /> + ), + "1": ( + + ), + "2": , + }} + /> +
+
)}
@@ -1394,6 +1479,68 @@ const ModesView = ({ onDone }: ModesViewProps) => {
)} + + {/* Import Mode Dialog */} + {showImportDialog && ( +
+
+

{t("prompts:modes.importMode")}

+

+ {t("prompts:importMode.selectLevel")} +

+
+ + +
+
+ + +
+
+
+ )} ) } diff --git a/webview-ui/src/i18n/locales/ca/prompts.json b/webview-ui/src/i18n/locales/ca/prompts.json index 04359f996a..27d74d3d26 100644 --- a/webview-ui/src/i18n/locales/ca/prompts.json +++ b/webview-ui/src/i18n/locales/ca/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "Modes", "createNewMode": "Crear nou mode", + "importMode": "Importar mode", + "noMatchFound": "No s'han trobat modes", "editModesConfig": "Editar configuració de modes", "editGlobalModes": "Editar modes globals", "editProjectModes": "Editar modes de projecte (.roomodes)", @@ -50,6 +52,28 @@ "description": "Afegiu directrius de comportament específiques per al mode {{modeName}}.", "loadFromFile": "Les instruccions personalitzades específiques per al mode {{mode}} també es poden carregar des de la carpeta .roo/rules-{{slug}}/ al vostre espai de treball (.roorules-{{slug}} i .clinerules-{{slug}} estan obsolets i deixaran de funcionar aviat)." }, + "exportMode": { + "title": "Exportar mode", + "description": "Exporta aquest mode a un fitxer YAML amb totes les regles incloses per compartir fàcilment amb altres.", + "export": "Exportar mode", + "exporting": "Exportant..." + }, + "importMode": { + "selectLevel": "Tria on importar aquest mode:", + "import": "Importar", + "importing": "Important...", + "global": { + "label": "Nivell global", + "description": "Disponible a tots els projectes. Les regles es fusionaran amb les instruccions personalitzades." + }, + "project": { + "label": "Nivell de projecte", + "description": "Només disponible en aquest espai de treball. Si el mode exportat contenia fitxers de regles, es tornaran a crear a la carpeta .roo/rules-{slug}/." + } + }, + "advanced": { + "title": "Avançat" + }, "globalCustomInstructions": { "title": "Instruccions personalitzades per a tots els modes", "description": "Aquestes instruccions s'apliquen a tots els modes. Proporcionen un conjunt bàsic de comportaments que es poden millorar amb instruccions específiques de cada mode a continuació. <0>Més informació", diff --git a/webview-ui/src/i18n/locales/de/prompts.json b/webview-ui/src/i18n/locales/de/prompts.json index 6e9fd0f47b..11c132f1df 100644 --- a/webview-ui/src/i18n/locales/de/prompts.json +++ b/webview-ui/src/i18n/locales/de/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "Modi", "createNewMode": "Neuen Modus erstellen", + "importMode": "Modus importieren", + "noMatchFound": "Keine Modi gefunden", "editModesConfig": "Moduskonfiguration bearbeiten", "editGlobalModes": "Globale Modi bearbeiten", "editProjectModes": "Projektmodi bearbeiten (.roomodes)", @@ -50,6 +52,28 @@ "description": "Fügen Sie verhaltensspezifische Richtlinien für den Modus {{modeName}} hinzu.", "loadFromFile": "Benutzerdefinierte Anweisungen für den Modus {{mode}} können auch aus dem Ordner .roo/rules-{{slug}}/ in deinem Arbeitsbereich geladen werden (.roorules-{{slug}} und .clinerules-{{slug}} sind veraltet und werden bald nicht mehr funktionieren)." }, + "exportMode": { + "title": "Modus exportieren", + "description": "Exportiert diesen Modus in eine YAML-Datei mit allen enthaltenen Regeln zum einfachen Teilen mit anderen.", + "export": "Modus exportieren", + "exporting": "Exportieren..." + }, + "importMode": { + "selectLevel": "Wähle, wo dieser Modus importiert werden soll:", + "import": "Importieren", + "importing": "Importiere...", + "global": { + "label": "Globale Ebene", + "description": "Verfügbar in allen Projekten. Wenn der exportierte Modus Regeldateien enthielt, werden diese im globalen Ordner .roo/rules-{slug}/ neu erstellt." + }, + "project": { + "label": "Projektebene", + "description": "Nur in diesem Arbeitsbereich verfügbar. Wenn der exportierte Modus Regeldateien enthielt, werden diese im Ordner .roo/rules-{slug}/ neu erstellt." + } + }, + "advanced": { + "title": "Erweitert" + }, "globalCustomInstructions": { "title": "Benutzerdefinierte Anweisungen für alle Modi", "description": "Diese Anweisungen gelten für alle Modi. Sie bieten einen grundlegenden Satz von Verhaltensweisen, die durch modusspezifische Anweisungen unten erweitert werden können. <0>Mehr erfahren", diff --git a/webview-ui/src/i18n/locales/en/prompts.json b/webview-ui/src/i18n/locales/en/prompts.json index 3614d79872..df13c8773f 100644 --- a/webview-ui/src/i18n/locales/en/prompts.json +++ b/webview-ui/src/i18n/locales/en/prompts.json @@ -4,11 +4,13 @@ "modes": { "title": "Modes", "createNewMode": "Create new mode", + "importMode": "Import Mode", "editModesConfig": "Edit modes configuration", "editGlobalModes": "Edit Global Modes", "editProjectModes": "Edit Project Modes (.roomodes)", "createModeHelpText": "Modes are specialized personas that tailor Roo's behavior. <0>Learn about Using Modes or <1>Customizing Modes.", - "selectMode": "Search modes" + "selectMode": "Search modes", + "noMatchFound": "No modes found" }, "apiConfiguration": { "title": "API Configuration", @@ -50,6 +52,27 @@ "description": "Add behavioral guidelines specific to {{modeName}} mode.", "loadFromFile": "Custom instructions specific to {{mode}} mode can also be loaded from the .roo/rules-{{slug}}/ folder in your workspace (.roorules-{{slug}} and .clinerules-{{slug}} are deprecated and will stop working soon)." }, + "exportMode": { + "title": "Export Mode", + "description": "Export this mode with rules from the .roo/rules-{{slug}}/ folder combined into a shareable YAML file. The original files remain unchanged.", + "exporting": "Exporting..." + }, + "importMode": { + "selectLevel": "Choose where to import this mode:", + "import": "Import", + "importing": "Importing...", + "global": { + "label": "Global Level", + "description": "Available across all projects. If the exported mode contained rules files, they will be recreated in the global .roo/rules-{slug}/ folder." + }, + "project": { + "label": "Project Level", + "description": "Only available in this workspace. If the exported mode contained rules files, they will be recreated in .roo/rules-{slug}/ folder." + } + }, + "advanced": { + "title": "Advanced: Override System Prompt" + }, "globalCustomInstructions": { "title": "Custom Instructions for All Modes", "description": "These instructions apply to all modes. They provide a base set of behaviors that can be enhanced by mode-specific instructions below. <0>Learn more", diff --git a/webview-ui/src/i18n/locales/es/prompts.json b/webview-ui/src/i18n/locales/es/prompts.json index 54b5c1bd2d..67a4d25b8a 100644 --- a/webview-ui/src/i18n/locales/es/prompts.json +++ b/webview-ui/src/i18n/locales/es/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "Modos", "createNewMode": "Crear nuevo modo", + "importMode": "Importar modo", + "noMatchFound": "No se encontraron modos", "editModesConfig": "Editar configuración de modos", "editGlobalModes": "Editar modos globales", "editProjectModes": "Editar modos del proyecto (.roomodes)", @@ -50,6 +52,28 @@ "description": "Agrega directrices de comportamiento específicas para el modo {{modeName}}.", "loadFromFile": "Las instrucciones personalizadas para el modo {{mode}} también se pueden cargar desde la carpeta .roo/rules-{{slug}}/ en tu espacio de trabajo (.roorules-{{slug}} y .clinerules-{{slug}} están obsoletos y dejarán de funcionar pronto)." }, + "exportMode": { + "title": "Exportar modo", + "description": "Exporta este modo a un archivo YAML con todas las reglas incluidas para compartir fácilmente con otros.", + "export": "Exportar modo", + "exporting": "Exportando..." + }, + "importMode": { + "selectLevel": "Elige dónde importar este modo:", + "import": "Importar", + "importing": "Importando...", + "global": { + "label": "Nivel global", + "description": "Disponible en todos los proyectos. Si el modo exportado contenía archivos de reglas, se volverán a crear en la carpeta global .roo/rules-{slug}/." + }, + "project": { + "label": "Nivel de proyecto", + "description": "Solo disponible en este espacio de trabajo. Si el modo exportado contenía archivos de reglas, se volverán a crear en la carpeta .roo/rules-{slug}/." + } + }, + "advanced": { + "title": "Avanzado" + }, "globalCustomInstructions": { "title": "Instrucciones personalizadas para todos los modos", "description": "Estas instrucciones se aplican a todos los modos. Proporcionan un conjunto base de comportamientos que pueden ser mejorados por instrucciones específicas de cada modo a continuación. <0>Más información", diff --git a/webview-ui/src/i18n/locales/fr/prompts.json b/webview-ui/src/i18n/locales/fr/prompts.json index 39bc67e482..202e214322 100644 --- a/webview-ui/src/i18n/locales/fr/prompts.json +++ b/webview-ui/src/i18n/locales/fr/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "Modes", "createNewMode": "Créer un nouveau mode", + "importMode": "Importer le mode", + "noMatchFound": "Aucun mode trouvé", "editModesConfig": "Modifier la configuration des modes", "editGlobalModes": "Modifier les modes globaux", "editProjectModes": "Modifier les modes du projet (.roomodes)", @@ -50,6 +52,28 @@ "description": "Ajoutez des directives comportementales spécifiques au mode {{modeName}}.", "loadFromFile": "Les instructions personnalisées spécifiques au mode {{mode}} peuvent également être chargées depuis le dossier .roo/rules-{{slug}}/ dans votre espace de travail (.roorules-{{slug}} et .clinerules-{{slug}} sont obsolètes et cesseront de fonctionner bientôt)." }, + "exportMode": { + "title": "Exporter le mode", + "description": "Exporte ce mode vers un fichier YAML avec toutes les règles incluses pour un partage facile avec d'autres.", + "export": "Exporter le mode", + "exporting": "Exportation..." + }, + "importMode": { + "selectLevel": "Choisissez où importer ce mode :", + "import": "Importer", + "importing": "Importation...", + "global": { + "label": "Niveau global", + "description": "Disponible dans tous les projets. Si le mode exporté contenait des fichiers de règles, ils seront recréés dans le dossier global .roo/rules-{slug}/." + }, + "project": { + "label": "Niveau projet", + "description": "Disponible uniquement dans cet espace de travail. Si le mode exporté contenait des fichiers de règles, ils seront recréés dans le dossier .roo/rules-{slug}/." + } + }, + "advanced": { + "title": "Avancé" + }, "globalCustomInstructions": { "title": "Instructions personnalisées pour tous les modes", "description": "Ces instructions s'appliquent à tous les modes. Elles fournissent un ensemble de comportements de base qui peuvent être améliorés par des instructions spécifiques au mode ci-dessous. <0>En savoir plus", diff --git a/webview-ui/src/i18n/locales/hi/prompts.json b/webview-ui/src/i18n/locales/hi/prompts.json index 9633b02953..ea1c4b6ae1 100644 --- a/webview-ui/src/i18n/locales/hi/prompts.json +++ b/webview-ui/src/i18n/locales/hi/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "मोड्स", "createNewMode": "नया मोड बनाएँ", + "importMode": "मोड आयात करें", + "noMatchFound": "कोई मोड नहीं मिला", "editModesConfig": "मोड कॉन्फ़िगरेशन संपादित करें", "editGlobalModes": "ग्लोबल मोड्स संपादित करें", "editProjectModes": "प्रोजेक्ट मोड्स संपादित करें (.roomodes)", @@ -50,6 +52,28 @@ "description": "{{modeName}} मोड के लिए विशिष्ट व्यवहार दिशानिर्देश जोड़ें।", "loadFromFile": "{{mode}} मोड के लिए विशिष्ट कस्टम निर्देश आपके वर्कस्पेस में .roo/rules-{{slug}}/ फ़ोल्डर से भी लोड किए जा सकते हैं (.roorules-{{slug}} और .clinerules-{{slug}} पुराने हो गए हैं और जल्द ही काम करना बंद कर देंगे)।" }, + "exportMode": { + "title": "मोड निर्यात करें", + "description": "इस मोड को सभी नियमों के साथ एक YAML फ़ाइल में निर्यात करें ताकि दूसरों के साथ आसानी से साझा किया जा सके।", + "export": "मोड निर्यात करें", + "exporting": "निर्यात हो रहा है..." + }, + "importMode": { + "selectLevel": "चुनें कि इस मोड को कहाँ आयात करना है:", + "import": "आयात करें", + "importing": "आयात कर रहे हैं...", + "global": { + "label": "वैश्विक स्तर", + "description": "सभी परियोजनाओं में उपलब्ध। नियम कस्टम निर्देशों में विलय कर दिए जाएंगे।" + }, + "project": { + "label": "परियोजना स्तर", + "description": "केवल इस कार्यक्षेत्र में उपलब्ध। यदि निर्यात किए गए मोड में नियम फाइलें थीं, तो उन्हें .roo/rules-{slug}/ फ़ोल्डर में फिर से बनाया जाएगा।" + } + }, + "advanced": { + "title": "उन्नत" + }, "globalCustomInstructions": { "title": "सभी मोड्स के लिए कस्टम निर्देश", "description": "ये निर्देश सभी मोड्स पर लागू होते हैं। वे व्यवहारों का एक आधार सेट प्रदान करते हैं जिन्हें नीचे दिए गए मोड-विशिष्ट निर्देशों द्वारा बढ़ाया जा सकता है। <0>और जानें", diff --git a/webview-ui/src/i18n/locales/id/prompts.json b/webview-ui/src/i18n/locales/id/prompts.json index a77a6e5376..bdebc4eb73 100644 --- a/webview-ui/src/i18n/locales/id/prompts.json +++ b/webview-ui/src/i18n/locales/id/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "Mode", "createNewMode": "Buat mode baru", + "importMode": "Impor mode", + "noMatchFound": "Tidak ada mode yang ditemukan", "editModesConfig": "Edit konfigurasi mode", "editGlobalModes": "Edit Mode Global", "editProjectModes": "Edit Mode Proyek (.roomodes)", @@ -50,6 +52,28 @@ "description": "Tambahkan panduan perilaku khusus untuk mode {{modeName}}.", "loadFromFile": "Instruksi kustom khusus untuk mode {{mode}} juga dapat dimuat dari folder .roo/rules-{{slug}}/ di workspace Anda (.roomodes-{{slug}} dan .clinerules-{{slug}} sudah deprecated dan akan segera berhenti bekerja)." }, + "exportMode": { + "title": "Ekspor Mode", + "description": "Ekspor mode ini ke file YAML dengan semua aturan yang disertakan untuk berbagi dengan mudah dengan orang lain.", + "export": "Ekspor Mode", + "exporting": "Mengekspor..." + }, + "importMode": { + "selectLevel": "Pilih di mana akan mengimpor mode ini:", + "import": "Impor", + "importing": "Mengimpor...", + "global": { + "label": "Tingkat Global", + "description": "Tersedia di semua proyek. Aturan akan digabungkan ke dalam instruksi kustom." + }, + "project": { + "label": "Tingkat Proyek", + "description": "Hanya tersedia di ruang kerja ini. Jika mode yang diekspor berisi file aturan, file tersebut akan dibuat ulang di folder .roo/rules-{slug}/." + } + }, + "advanced": { + "title": "Lanjutan" + }, "globalCustomInstructions": { "title": "Instruksi Kustom untuk Semua Mode", "description": "Instruksi ini berlaku untuk semua mode. Mereka menyediakan set dasar perilaku yang dapat ditingkatkan oleh instruksi khusus mode di bawah. <0>Pelajari lebih lanjut", diff --git a/webview-ui/src/i18n/locales/it/prompts.json b/webview-ui/src/i18n/locales/it/prompts.json index c556a18aac..5f6fd165aa 100644 --- a/webview-ui/src/i18n/locales/it/prompts.json +++ b/webview-ui/src/i18n/locales/it/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "Modalità", "createNewMode": "Crea nuova modalità", + "importMode": "Importa modalità", + "noMatchFound": "Nessuna modalità trovata", "editModesConfig": "Modifica configurazione modalità", "editGlobalModes": "Modifica modalità globali", "editProjectModes": "Modifica modalità di progetto (.roomodes)", @@ -50,6 +52,28 @@ "description": "Aggiungi linee guida comportamentali specifiche per la modalità {{modeName}}.", "loadFromFile": "Le istruzioni personalizzate specifiche per la modalità {{mode}} possono essere caricate anche dalla cartella .roo/rules-{{slug}}/ nel tuo spazio di lavoro (.roorules-{{slug}} e .clinerules-{{slug}} sono obsoleti e smetteranno di funzionare presto)." }, + "exportMode": { + "title": "Esporta modalità", + "description": "Esporta questa modalità in un file YAML con tutte le regole incluse per una facile condivisione con altri.", + "export": "Esporta modalità", + "exporting": "Esportazione..." + }, + "importMode": { + "selectLevel": "Scegli dove importare questa modalità:", + "import": "Importa", + "importing": "Importazione...", + "global": { + "label": "Livello globale", + "description": "Disponibile in tutti i progetti. Le regole verranno unite nelle istruzioni personalizzate." + }, + "project": { + "label": "Livello di progetto", + "description": "Disponibile solo in questo spazio di lavoro. Se la modalità esportata conteneva file di regole, verranno ricreati nella cartella .roo/rules-{slug}/." + } + }, + "advanced": { + "title": "Avanzato" + }, "globalCustomInstructions": { "title": "Istruzioni personalizzate per tutte le modalità", "description": "Queste istruzioni si applicano a tutte le modalità. Forniscono un insieme base di comportamenti che possono essere migliorati dalle istruzioni specifiche per modalità qui sotto. <0>Scopri di più", diff --git a/webview-ui/src/i18n/locales/ja/prompts.json b/webview-ui/src/i18n/locales/ja/prompts.json index 8049a82d31..1fed98d194 100644 --- a/webview-ui/src/i18n/locales/ja/prompts.json +++ b/webview-ui/src/i18n/locales/ja/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "モード", "createNewMode": "新しいモードを作成", + "importMode": "モードをインポート", + "noMatchFound": "モードが見つかりません", "editModesConfig": "モード設定を編集", "editGlobalModes": "グローバルモードを編集", "editProjectModes": "プロジェクトモードを編集 (.roomodes)", @@ -50,6 +52,28 @@ "description": "{{modeName}}モードに特化した行動ガイドラインを追加します。", "loadFromFile": "{{mode}}モード固有のカスタム指示は、ワークスペースの.roo/rules-{{slug}}/フォルダからも読み込めます(.roorules-{{slug}}と.clinerules-{{slug}}は非推奨であり、まもなく機能しなくなります)。" }, + "exportMode": { + "title": "モードをエクスポート", + "description": "このモードをすべてのルールを含むYAMLファイルにエクスポートして、他のユーザーと簡単に共有できます。", + "export": "モードをエクスポート", + "exporting": "エクスポート中..." + }, + "importMode": { + "selectLevel": "このモードをインポートする場所を選択してください:", + "import": "インポート", + "importing": "インポート中...", + "global": { + "label": "グローバルレベル", + "description": "すべてのプロジェクトで利用可能です。ルールはカスタム指示にマージされます。" + }, + "project": { + "label": "プロジェクトレベル", + "description": "このワークスペースでのみ利用可能です。エクスポートされたモードにルールファイルが含まれていた場合、それらは.roo/rules-{slug}/フォルダに再作成されます。" + } + }, + "advanced": { + "title": "詳細設定" + }, "globalCustomInstructions": { "title": "すべてのモードのカスタム指示", "description": "これらの指示はすべてのモードに適用されます。モード固有の指示で強化できる基本的な動作セットを提供します。<0>詳細はこちら", diff --git a/webview-ui/src/i18n/locales/ko/prompts.json b/webview-ui/src/i18n/locales/ko/prompts.json index 990ee67f03..6625f2e957 100644 --- a/webview-ui/src/i18n/locales/ko/prompts.json +++ b/webview-ui/src/i18n/locales/ko/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "모드", "createNewMode": "새 모드 만들기", + "importMode": "모드 가져오기", + "noMatchFound": "모드를 찾을 수 없습니다", "editModesConfig": "모드 구성 편집", "editGlobalModes": "전역 모드 편집", "editProjectModes": "프로젝트 모드 편집 (.roomodes)", @@ -50,6 +52,28 @@ "description": "{{modeName}} 모드에 대한 특정 행동 지침을 추가하세요.", "loadFromFile": "{{mode}} 모드에 대한 사용자 지정 지침은 작업 공간의 .roo/rules-{{slug}}/ 폴더에서도 로드할 수 있습니다(.roorules-{{slug}}와 .clinerules-{{slug}}는 더 이상 사용되지 않으며 곧 작동을 중단합니다)." }, + "exportMode": { + "title": "모드 내보내기", + "description": "이 모드를 모든 규칙이 포함된 YAML 파일로 내보내어 다른 사람들과 쉽게 공유할 수 있습니다.", + "export": "모드 내보내기", + "exporting": "내보내는 중..." + }, + "importMode": { + "selectLevel": "이 모드를 가져올 위치를 선택하세요:", + "import": "가져오기", + "importing": "가져오는 중...", + "global": { + "label": "전역 수준", + "description": "모든 프로젝트에서 사용 가능합니다. 규칙은 사용자 지정 지침에 병합됩니다." + }, + "project": { + "label": "프로젝트 수준", + "description": "이 작업 공간에서만 사용할 수 있습니다. 내보낸 모드에 규칙 파일이 포함된 경우 .roo/rules-{slug}/ 폴더에 다시 생성됩니다." + } + }, + "advanced": { + "title": "고급" + }, "globalCustomInstructions": { "title": "모든 모드에 대한 사용자 지정 지침", "description": "이 지침은 모든 모드에 적용됩니다. 아래의 모드별 지침으로 향상될 수 있는 기본 동작 세트를 제공합니다. <0>더 알아보기", diff --git a/webview-ui/src/i18n/locales/nl/prompts.json b/webview-ui/src/i18n/locales/nl/prompts.json index 2aa09a5a15..c472117ea5 100644 --- a/webview-ui/src/i18n/locales/nl/prompts.json +++ b/webview-ui/src/i18n/locales/nl/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "Modi", "createNewMode": "Nieuwe modus aanmaken", + "importMode": "Modus importeren", + "noMatchFound": "Geen modi gevonden", "editModesConfig": "Modusconfiguratie bewerken", "editGlobalModes": "Globale modi bewerken", "editProjectModes": "Projectmodi bewerken (.roomodes)", @@ -50,6 +52,28 @@ "description": "Voeg gedragsrichtlijnen toe die specifiek zijn voor de modus {{modeName}}.", "loadFromFile": "Modusspecifieke instructies voor {{mode}} kunnen ook worden geladen uit de map .roo/rules-{{slug}}/ in je werkruimte (.roorules-{{slug}} en .clinerules-{{slug}} zijn verouderd en werken binnenkort niet meer)." }, + "exportMode": { + "title": "Modus exporteren", + "description": "Exporteer deze modus naar een YAML-bestand met alle regels inbegrepen voor eenvoudig delen met anderen.", + "export": "Modus exporteren", + "exporting": "Exporteren..." + }, + "importMode": { + "selectLevel": "Kies waar je deze modus wilt importeren:", + "import": "Importeren", + "importing": "Importeren...", + "global": { + "label": "Globaal niveau", + "description": "Beschikbaar in alle projecten. Regels worden samengevoegd in aangepaste instructies." + }, + "project": { + "label": "Projectniveau", + "description": "Alleen beschikbaar in deze werkruimte. Als de geëxporteerde modus regelbestanden bevatte, worden deze opnieuw gemaakt in de map .roo/rules-{slug}/." + } + }, + "advanced": { + "title": "Geavanceerd" + }, "globalCustomInstructions": { "title": "Aangepaste instructies voor alle modi", "description": "Deze instructies gelden voor alle modi. Ze bieden een basisset aan gedragingen die kunnen worden uitgebreid met modusspecifieke instructies hieronder. <0>Meer informatie", diff --git a/webview-ui/src/i18n/locales/pl/prompts.json b/webview-ui/src/i18n/locales/pl/prompts.json index b4a1bdcc50..3cd76ddd73 100644 --- a/webview-ui/src/i18n/locales/pl/prompts.json +++ b/webview-ui/src/i18n/locales/pl/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "Tryby", "createNewMode": "Utwórz nowy tryb", + "importMode": "Importuj tryb", + "noMatchFound": "Nie znaleziono trybów", "editModesConfig": "Edytuj konfigurację trybów", "editGlobalModes": "Edytuj tryby globalne", "editProjectModes": "Edytuj tryby projektu (.roomodes)", @@ -50,6 +52,28 @@ "description": "Dodaj wytyczne dotyczące zachowania specyficzne dla trybu {{modeName}}.", "loadFromFile": "Niestandardowe instrukcje dla trybu {{mode}} mogą być również ładowane z folderu .roo/rules-{{slug}}/ w Twoim obszarze roboczym (.roorules-{{slug}} i .clinerules-{{slug}} są przestarzałe i wkrótce przestaną działać)." }, + "exportMode": { + "title": "Eksportuj tryb", + "description": "Eksportuj ten tryb do pliku YAML ze wszystkimi regułami w celu łatwego udostępniania innym.", + "export": "Eksportuj tryb", + "exporting": "Eksportowanie..." + }, + "importMode": { + "selectLevel": "Wybierz, gdzie zaimportować ten tryb:", + "import": "Importuj", + "importing": "Importowanie...", + "global": { + "label": "Poziom globalny", + "description": "Dostępne we wszystkich projektach. Reguły zostaną scalone z niestandardowymi instrukcjami." + }, + "project": { + "label": "Poziom projektu", + "description": "Dostępne tylko w tym obszarze roboczym. Jeśli wyeksportowany tryb zawierał pliki reguł, zostaną one odtworzone w folderze .roo/rules-{slug}/." + } + }, + "advanced": { + "title": "Zaawansowane" + }, "globalCustomInstructions": { "title": "Niestandardowe instrukcje dla wszystkich trybów", "description": "Te instrukcje dotyczą wszystkich trybów. Zapewniają podstawowy zestaw zachowań, które mogą być rozszerzone przez instrukcje specyficzne dla trybów poniżej. <0>Dowiedz się więcej", diff --git a/webview-ui/src/i18n/locales/pt-BR/prompts.json b/webview-ui/src/i18n/locales/pt-BR/prompts.json index c2a88d4eaa..3e456572b7 100644 --- a/webview-ui/src/i18n/locales/pt-BR/prompts.json +++ b/webview-ui/src/i18n/locales/pt-BR/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "Modos", "createNewMode": "Criar novo modo", + "importMode": "Importar modo", + "noMatchFound": "Nenhum modo encontrado", "editModesConfig": "Editar configuração de modos", "editGlobalModes": "Editar modos globais", "editProjectModes": "Editar modos do projeto (.roomodes)", @@ -50,6 +52,28 @@ "description": "Adicione diretrizes comportamentais específicas para o modo {{modeName}}.", "loadFromFile": "Instruções personalizadas específicas para o modo {{mode}} também podem ser carregadas da pasta .roo/rules-{{slug}}/ no seu espaço de trabalho (.roorules-{{slug}} e .clinerules-{{slug}} estão obsoletos e deixarão de funcionar em breve)." }, + "exportMode": { + "title": "Exportar modo", + "description": "Exporte este modo para um arquivo YAML com todas as regras incluídas para compartilhar facilmente com outros.", + "export": "Exportar modo", + "exporting": "Exportando..." + }, + "importMode": { + "selectLevel": "Escolha onde importar este modo:", + "import": "Importar", + "importing": "Importando...", + "global": { + "label": "Nível global", + "description": "Disponível em todos os projetos. As regras serão mescladas nas instruções personalizadas." + }, + "project": { + "label": "Nível do projeto", + "description": "Disponível apenas neste espaço de trabalho. Se o modo exportado continha arquivos de regras, eles serão recriados na pasta .roo/rules-{slug}/." + } + }, + "advanced": { + "title": "Avançado" + }, "globalCustomInstructions": { "title": "Instruções personalizadas para todos os modos", "description": "Estas instruções se aplicam a todos os modos. Elas fornecem um conjunto base de comportamentos que podem ser aprimorados por instruções específicas do modo abaixo. <0>Saiba mais", diff --git a/webview-ui/src/i18n/locales/ru/prompts.json b/webview-ui/src/i18n/locales/ru/prompts.json index 07e9f91db8..54fbeb24a6 100644 --- a/webview-ui/src/i18n/locales/ru/prompts.json +++ b/webview-ui/src/i18n/locales/ru/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "Режимы", "createNewMode": "Создать новый режим", + "importMode": "Импортировать режим", + "noMatchFound": "Режимы не найдены", "editModesConfig": "Редактировать конфигурацию режимов", "editGlobalModes": "Редактировать глобальные режимы", "editProjectModes": "Редактировать режимы проекта (.roomodes)", @@ -50,11 +52,30 @@ "description": "Добавьте рекомендации по поведению, специфичные для режима {{modeName}}.", "loadFromFile": "Пользовательские инструкции для режима {{mode}} также можно загрузить из папки .roo/rules-{{slug}}/ в вашем рабочем пространстве (.roorules-{{slug}} и .clinerules-{{slug}} устарели и скоро перестанут работать)." }, + "exportMode": { + "title": "Экспортировать режим", + "description": "Экспортировать этот режим в файл YAML со всеми включенными правилами для удобного обмена с другими.", + "export": "Экспортировать режим", + "exporting": "Экспорт..." + }, "globalCustomInstructions": { "title": "Пользовательские инструкции для всех режимов", "description": "Эти инструкции применяются ко всем режимам. Они задают базовое поведение, которое можно расширить с помощью инструкций ниже. <0>Узнать больше", "loadFromFile": "Инструкции также можно загрузить из папки .roo/rules/ в вашем рабочем пространстве (.roorules и .clinerules устарели и скоро перестанут работать)." }, + "importMode": { + "selectLevel": "Выберите, куда импортировать этот режим:", + "import": "Импорт", + "importing": "Импортирование...", + "global": { + "label": "Глобальный уровень", + "description": "Доступно во всех проектах. Правила будут объединены с пользовательскими инструкциями." + }, + "project": { + "label": "Уровень проекта", + "description": "Доступно только в этом рабочем пространстве. Если экспортированный режим содержал файлы правил, они будут воссозданы в папке .roo/rules-{slug}/." + } + }, "systemPrompt": { "preview": "Предпросмотр системного промпта", "copy": "Скопировать системный промпт в буфер обмена", @@ -164,5 +185,8 @@ }, "deleteMode": "Удалить режим" }, - "allFiles": "все файлы" + "allFiles": "все файлы", + "advanced": { + "title": "Дополнительно" + } } diff --git a/webview-ui/src/i18n/locales/tr/prompts.json b/webview-ui/src/i18n/locales/tr/prompts.json index d091456e43..0da97ec4c6 100644 --- a/webview-ui/src/i18n/locales/tr/prompts.json +++ b/webview-ui/src/i18n/locales/tr/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "Modlar", "createNewMode": "Yeni mod oluştur", + "importMode": "Modu içe aktar", + "noMatchFound": "Mod bulunamadı", "editModesConfig": "Mod yapılandırmasını düzenle", "editGlobalModes": "Global modları düzenle", "editProjectModes": "Proje modlarını düzenle (.roomodes)", @@ -50,11 +52,30 @@ "description": "{{modeName}} modu için özel davranış yönergeleri ekleyin.", "loadFromFile": "{{mode}} moduna özgü özel talimatlar ayrıca çalışma alanınızdaki .roo/rules-{{slug}}/ klasöründen yüklenebilir (.roorules-{{slug}} ve .clinerules-{{slug}} kullanımdan kaldırılmıştır ve yakında çalışmayı durduracaklardır)." }, + "exportMode": { + "title": "Modu Dışa Aktar", + "description": "Bu modu tüm kurallar dahil olarak bir YAML dosyasına dışa aktararak başkalarıyla kolayca paylaşın.", + "export": "Modu Dışa Aktar", + "exporting": "Dışa aktarılıyor..." + }, "globalCustomInstructions": { "title": "Tüm Modlar için Özel Talimatlar", "description": "Bu talimatlar tüm modlara uygulanır. Aşağıdaki moda özgü talimatlarla geliştirilebilen temel davranış seti sağlarlar. <0>Daha fazla bilgi edinin", "loadFromFile": "Talimatlar ayrıca çalışma alanınızdaki .roo/rules/ klasöründen de yüklenebilir (.roorules ve .clinerules kullanımdan kaldırılmıştır ve yakında çalışmayı durduracaklardır)." }, + "importMode": { + "selectLevel": "Bu modu nereye içe aktaracağınızı seçin:", + "import": "İçe Aktar", + "importing": "İçe aktarılıyor...", + "global": { + "label": "Genel Seviye", + "description": "Tüm projelerde kullanılabilir. Kurallar özel talimatlarla birleştirilecektir." + }, + "project": { + "label": "Proje Seviyesi", + "description": "Yalnızca bu çalışma alanında kullanılabilir. Dışa aktarılan mod kural dosyaları içeriyorsa, bunlar .roo/rules-{slug}/ klasöründe yeniden oluşturulur." + } + }, "systemPrompt": { "preview": "Sistem promptunu önizle", "copy": "Sistem promptunu panoya kopyala", @@ -164,5 +185,8 @@ }, "deleteMode": "Modu sil" }, - "allFiles": "tüm dosyalar" + "allFiles": "tüm dosyalar", + "advanced": { + "title": "Gelişmiş" + } } diff --git a/webview-ui/src/i18n/locales/vi/prompts.json b/webview-ui/src/i18n/locales/vi/prompts.json index 7a0b311a02..1dadec8f02 100644 --- a/webview-ui/src/i18n/locales/vi/prompts.json +++ b/webview-ui/src/i18n/locales/vi/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "Chế độ", "createNewMode": "Tạo chế độ mới", + "importMode": "Nhập chế độ", + "noMatchFound": "Không tìm thấy chế độ nào", "editModesConfig": "Chỉnh sửa cấu hình chế độ", "editGlobalModes": "Chỉnh sửa chế độ toàn cục", "editProjectModes": "Chỉnh sửa chế độ dự án (.roomodes)", @@ -50,11 +52,30 @@ "description": "Thêm hướng dẫn hành vi dành riêng cho chế độ {{modeName}}.", "loadFromFile": "Hướng dẫn tùy chỉnh dành riêng cho chế độ {{mode}} cũng có thể được tải từ thư mục .roo/rules-{{slug}}/ trong không gian làm việc của bạn (.roorules-{{slug}} và .clinerules-{{slug}} đã lỗi thời và sẽ sớm ngừng hoạt động)." }, + "exportMode": { + "title": "Xuất chế độ", + "description": "Xuất chế độ này sang tệp YAML với tất cả các quy tắc được bao gồm để dễ dàng chia sẻ với người khác.", + "export": "Xuất chế độ", + "exporting": "Đang xuất..." + }, "globalCustomInstructions": { "title": "Hướng dẫn tùy chỉnh cho tất cả các chế độ", "description": "Những hướng dẫn này áp dụng cho tất cả các chế độ. Chúng cung cấp một bộ hành vi cơ bản có thể được nâng cao bởi hướng dẫn dành riêng cho chế độ bên dưới. <0>Tìm hiểu thêm", "loadFromFile": "Hướng dẫn cũng có thể được tải từ thư mục .roo/rules/ trong không gian làm việc của bạn (.roorules và .clinerules đã lỗi thời và sẽ sớm ngừng hoạt động)." }, + "importMode": { + "selectLevel": "Chọn nơi để nhập chế độ này:", + "import": "Nhập", + "importing": "Đang nhập...", + "global": { + "label": "Cấp độ toàn cục", + "description": "Có sẵn trong tất cả các dự án. Các quy tắc sẽ được hợp nhất vào hướng dẫn tùy chỉnh." + }, + "project": { + "label": "Cấp độ dự án", + "description": "Chỉ có sẵn trong không gian làm việc này. Nếu chế độ đã xuất có chứa tệp quy tắc, chúng sẽ được tạo lại trong thư mục .roo/rules-{slug}/." + } + }, "systemPrompt": { "preview": "Xem trước lời nhắc hệ thống", "copy": "Sao chép lời nhắc hệ thống vào bộ nhớ tạm", @@ -164,5 +185,8 @@ }, "deleteMode": "Xóa chế độ" }, - "allFiles": "tất cả các tệp" + "allFiles": "tất cả các tệp", + "advanced": { + "title": "Nâng cao" + } } diff --git a/webview-ui/src/i18n/locales/zh-CN/prompts.json b/webview-ui/src/i18n/locales/zh-CN/prompts.json index 2abf922b14..5b67b41bec 100644 --- a/webview-ui/src/i18n/locales/zh-CN/prompts.json +++ b/webview-ui/src/i18n/locales/zh-CN/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "模式配置", "createNewMode": "新建模式", + "importMode": "导入模式", + "noMatchFound": "未找到任何模式", "editModesConfig": "模式设置", "editGlobalModes": "修改全局模式", "editProjectModes": "编辑项目模式 (.roomodes)", @@ -50,6 +52,25 @@ "description": "{{modeName}}模式的专属规则", "loadFromFile": "支持从.roo/rules-{{slug}}/目录读取配置(.roorules-{{slug}}和.clinerules-{{slug}}已弃用并将很快停止工作)。" }, + "exportMode": { + "title": "导出模式", + "description": "将此模式导出为包含所有规则的 YAML 文件,以便与他人轻松共享。", + "export": "导出模式", + "exporting": "正在导出..." + }, + "importMode": { + "selectLevel": "选择导入模式的位置:", + "import": "导入", + "importing": "导入中...", + "global": { + "label": "全局", + "description": "适用于所有项目。如果导出的模式包含规则文件,则将在全局 .roo/rules-{slug}/ 文件夹中重新创建这些文件。" + }, + "project": { + "label": "项目级", + "description": "仅在此工作区可用。如果导出的模式包含规则文件,则将在 .roo/rules-{slug}/ 文件夹中重新创建这些文件。" + } + }, "globalCustomInstructions": { "title": "所有模式的自定义指令", "description": "这些指令适用于所有模式。它们提供了一套基础行为,可以通过下面的模式特定指令进行增强。<0>了解更多", @@ -164,5 +185,8 @@ }, "deleteMode": "删除模式" }, - "allFiles": "所有文件" + "allFiles": "所有文件", + "advanced": { + "title": "高级" + } } diff --git a/webview-ui/src/i18n/locales/zh-TW/prompts.json b/webview-ui/src/i18n/locales/zh-TW/prompts.json index e853a5d91d..9e03e35147 100644 --- a/webview-ui/src/i18n/locales/zh-TW/prompts.json +++ b/webview-ui/src/i18n/locales/zh-TW/prompts.json @@ -4,6 +4,8 @@ "modes": { "title": "模式", "createNewMode": "建立新模式", + "importMode": "匯入模式", + "noMatchFound": "找不到任何模式", "editModesConfig": "編輯模式設定", "editGlobalModes": "編輯全域模式", "editProjectModes": "編輯專案模式 (.roomodes)", @@ -50,6 +52,25 @@ "description": "為 {{modeName}} 模式新增專屬的行為指南。", "loadFromFile": "{{mode}} 模式的自訂指令也可以從工作區的 .roo/rules-{{slug}}/ 資料夾載入(.roorules-{{slug}} 和 .clinerules-{{slug}} 已棄用並將很快停止運作)。" }, + "exportMode": { + "title": "匯出模式", + "description": "將此模式匯出為包含所有規則的 YAML 檔案,以便與他人輕鬆分享。", + "export": "匯出模式", + "exporting": "正在匯出..." + }, + "importMode": { + "selectLevel": "選擇匯入模式的位置:", + "import": "匯入", + "importing": "匯入中...", + "global": { + "label": "全域", + "description": "適用於所有專案。規則將合併到自訂指令中。" + }, + "project": { + "label": "專案級", + "description": "僅在此工作區可用。如果匯出的模式包含規則檔案,則將在 .roo/rules-{slug}/ 資料夾中重新建立這些檔案。" + } + }, "globalCustomInstructions": { "title": "所有模式的自訂指令", "description": "這些指令適用於所有模式。它們提供了一組基本行為,可以透過下方的模式專屬自訂指令來強化。<0>了解更多", @@ -164,5 +185,8 @@ }, "deleteMode": "刪除模式" }, - "allFiles": "所有檔案" + "allFiles": "所有檔案", + "advanced": { + "title": "進階" + } }