From 82171e2a2102286da9f9146837181498532a6b33 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sat, 21 Mar 2026 12:16:29 +0000 Subject: [PATCH] feat: add plugin system for installing extensions from GitHub repos Implements a plugin system that acts as a distribution and installation mechanism on top of existing Roo Code extension points (slash commands, custom modes, MCP servers, and skills). New components: - Plugin manifest schema (plugin.json) with Zod validation - GitHubSource: fetch plugin manifests and files from GitHub repos - PluginInstaller: install/remove commands, modes, MCP configs, skills - PluginManager: orchestrate install/remove/list with tracking - /plugin built-in command for agent-driven plugin management - 25 new tests covering all components Addresses #11974 --- packages/types/src/index.ts | 1 + packages/types/src/plugin.ts | 104 ++++++ src/services/command/built-in-commands.ts | 43 +++ src/services/plugin/GitHubSource.ts | 91 +++++ src/services/plugin/PluginInstaller.ts | 310 ++++++++++++++++++ src/services/plugin/PluginManager.ts | 165 ++++++++++ .../plugin/__tests__/GitHubSource.spec.ts | 195 +++++++++++ .../plugin/__tests__/PluginManager.spec.ts | 233 +++++++++++++ src/shared/globalFileNames.ts | 1 + 9 files changed, 1143 insertions(+) create mode 100644 packages/types/src/plugin.ts create mode 100644 src/services/plugin/GitHubSource.ts create mode 100644 src/services/plugin/PluginInstaller.ts create mode 100644 src/services/plugin/PluginManager.ts create mode 100644 src/services/plugin/__tests__/GitHubSource.spec.ts create mode 100644 src/services/plugin/__tests__/PluginManager.spec.ts diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index cd5804aecb7..993d2e293d3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -15,6 +15,7 @@ export * from "./history.js" export * from "./image-generation.js" export * from "./ipc.js" export * from "./marketplace.js" +export * from "./plugin.js" export * from "./mcp.js" export * from "./message.js" export * from "./mode.js" diff --git a/packages/types/src/plugin.ts b/packages/types/src/plugin.ts new file mode 100644 index 00000000000..08be7a5058d --- /dev/null +++ b/packages/types/src/plugin.ts @@ -0,0 +1,104 @@ +import { z } from "zod" + +/** + * Plugin manifest command entry - references a markdown command file in the plugin repo. + */ +export const pluginCommandSchema = z.object({ + name: z.string().min(1).describe("Command name (used as /command-name)"), + file: z.string().min(1).describe("Relative path to the command markdown file"), + description: z.string().optional().describe("Human-readable description of the command"), +}) + +export type PluginCommand = z.infer + +/** + * Plugin manifest mode entry - references a YAML mode definition file. + */ +export const pluginModeSchema = z.object({ + file: z.string().min(1).describe("Relative path to the mode YAML file"), +}) + +export type PluginMode = z.infer + +/** + * Plugin manifest skill entry - references a skill directory. + */ +export const pluginSkillSchema = z.object({ + name: z.string().min(1).describe("Skill name"), + directory: z.string().min(1).describe("Relative path to the skill directory"), +}) + +export type PluginSkill = z.infer + +/** + * MCP server configuration within a plugin manifest. + */ +export const pluginMcpServerSchema = z.record( + z.string(), + z.object({ + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + }), +) + +export type PluginMcpServers = z.infer + +/** + * The plugin manifest (plugin.json) found at the root of a plugin repository. + */ +export const pluginManifestSchema = z.object({ + name: z + .string() + .min(1) + .max(100) + .regex(/^[a-zA-Z0-9_-]+$/, "Plugin name must contain only letters, numbers, hyphens, and underscores"), + version: z + .string() + .regex(/^\d+\.\d+\.\d+$/, "Version must be in semver format (e.g. 1.0.0)") + .default("1.0.0"), + description: z.string().optional(), + author: z.string().optional(), + commands: z.array(pluginCommandSchema).optional().default([]), + modes: z.array(pluginModeSchema).optional().default([]), + mcpServers: pluginMcpServerSchema.optional(), + skills: z.array(pluginSkillSchema).optional().default([]), +}) + +export type PluginManifest = z.infer + +/** + * Tracks which extension points were installed by a plugin. + */ +export const installedExtensionsSchema = z.object({ + commands: z.array(z.string()).default([]), + modes: z.array(z.string()).default([]), + mcpServers: z.array(z.string()).default([]), + skills: z.array(z.string()).default([]), +}) + +export type InstalledExtensions = z.infer + +/** + * Record of an installed plugin, stored in plugins.json. + */ +export const installedPluginSchema = z.object({ + name: z.string(), + version: z.string(), + source: z.string().describe("GitHub owner/repo format"), + ref: z.string().default("main").describe("Git ref (branch, tag, or commit) used during install"), + installedAt: z.string().describe("ISO 8601 timestamp"), + target: z.enum(["project", "global"]), + installedExtensions: installedExtensionsSchema, +}) + +export type InstalledPlugin = z.infer + +/** + * The plugins tracking file schema (plugins.json). + */ +export const pluginsFileSchema = z.object({ + installedPlugins: z.array(installedPluginSchema).default([]), +}) + +export type PluginsFile = z.infer diff --git a/src/services/command/built-in-commands.ts b/src/services/command/built-in-commands.ts index db113c48959..4cb416e0a9e 100644 --- a/src/services/command/built-in-commands.ts +++ b/src/services/command/built-in-commands.ts @@ -284,6 +284,49 @@ Please analyze this codebase and create an AGENTS.md file containing: Remember: The goal is to create documentation that enables AI assistants to be immediately productive in this codebase, focusing on project-specific knowledge that isn't obvious from the code structure alone.`, }, + plugin: { + name: "plugin", + description: "Install, remove, or list plugins from GitHub repositories", + argumentHint: " [owner/repo or plugin-name] [--global]", + content: ` +Manage Roo Code plugins. Plugins are installable packages from GitHub repositories that bundle +slash commands, custom modes, MCP server configurations, and skills. + +Parse the user's arguments to determine the subcommand: + +1. **install owner/repo** - Install a plugin from a GitHub repository + - Fetches the plugin.json manifest from the repo root + - Installs all bundled extension points (commands, modes, MCP servers, skills) + - Tracks the installation in .roo/plugins.json + - Optional: Add @ref to specify a branch/tag (e.g., owner/repo@v1.0.0) + - Optional: Add --global to install globally (~/.roo/) instead of project-level + +2. **remove plugin-name** - Remove an installed plugin + - Cleans up all extension points that were installed by the plugin + - Removes the tracking record from plugins.json + - Optional: Add --global to remove from global installation + +3. **list** - List all installed plugins + - Shows both project-level and global plugins + - Displays name, version, source, and installed extension points + +A plugin repository must contain a plugin.json manifest at its root with this structure: +{ + "name": "plugin-name", + "version": "1.0.0", + "description": "What this plugin does", + "author": "author-name", + "commands": [{ "name": "command-name", "file": "commands/command-name.md" }], + "modes": [{ "file": "modes/mode-name.yaml" }], + "mcpServers": { "server-name": { "command": "npx", "args": ["-y", "package-name"] } }, + "skills": [{ "name": "skill-name", "directory": "skills/skill-name" }] +} + +IMPORTANT: Use the PluginManager service to perform these operations. Do not attempt to +manually create or modify plugin files. The PluginManager handles all validation, +file operations, and tracking automatically. +`, + }, } /** diff --git a/src/services/plugin/GitHubSource.ts b/src/services/plugin/GitHubSource.ts new file mode 100644 index 00000000000..a0c3f71b070 --- /dev/null +++ b/src/services/plugin/GitHubSource.ts @@ -0,0 +1,91 @@ +import type { PluginManifest } from "@roo-code/types" +import { pluginManifestSchema } from "@roo-code/types" + +/** + * Parsed plugin source reference. + */ +export interface PluginSourceRef { + owner: string + repo: string + ref: string // branch, tag, or commit - defaults to "main" +} + +/** + * Parse a plugin source string in the format "owner/repo" or "owner/repo@ref". + */ +export function parsePluginSource(source: string): PluginSourceRef { + const atIndex = source.indexOf("@") + let repoPath: string + let ref: string + + if (atIndex !== -1) { + repoPath = source.slice(0, atIndex) + ref = source.slice(atIndex + 1) + if (!ref) { + ref = "main" + } + } else { + repoPath = source + ref = "main" + } + + const parts = repoPath.split("/") + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error(`Invalid plugin source format: "${source}". Expected "owner/repo" or "owner/repo@ref".`) + } + + return { owner: parts[0], repo: parts[1], ref } +} + +/** + * Build a raw GitHub content URL for a specific file in a repository. + */ +export function buildRawUrl(sourceRef: PluginSourceRef, filePath: string): string { + return `https://raw.githubusercontent.com/${sourceRef.owner}/${sourceRef.repo}/${sourceRef.ref}/${filePath}` +} + +/** + * Fetch a file's text content from a GitHub repository. + */ +export async function fetchFileFromGitHub(sourceRef: PluginSourceRef, filePath: string): Promise { + const url = buildRawUrl(sourceRef, filePath) + const response = await fetch(url) + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`File not found: ${filePath} in ${sourceRef.owner}/${sourceRef.repo}@${sourceRef.ref}`) + } + throw new Error(`Failed to fetch ${filePath}: HTTP ${response.status} ${response.statusText}`) + } + + return response.text() +} + +/** + * Fetch and validate the plugin manifest (plugin.json) from a GitHub repository. + */ +export async function fetchPluginManifest(sourceRef: PluginSourceRef): Promise { + const content = await fetchFileFromGitHub(sourceRef, "plugin.json") + + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch { + throw new Error(`Invalid JSON in plugin.json from ${sourceRef.owner}/${sourceRef.repo}@${sourceRef.ref}`) + } + + const result = pluginManifestSchema.safeParse(parsed) + if (!result.success) { + const errors = result.error.issues + .map( + (issue: { path: (string | number)[]; message: string }) => + ` - ${issue.path.join(".")}: ${issue.message}`, + ) + .join("\n") + throw new Error( + `Invalid plugin manifest from ${sourceRef.owner}/${sourceRef.repo}@${sourceRef.ref}:\n${errors}`, + ) + } + + return result.data +} diff --git a/src/services/plugin/PluginInstaller.ts b/src/services/plugin/PluginInstaller.ts new file mode 100644 index 00000000000..63c44aabc05 --- /dev/null +++ b/src/services/plugin/PluginInstaller.ts @@ -0,0 +1,310 @@ +import * as fs from "fs/promises" +import * as path from "path" +import * as yaml from "yaml" + +import type { PluginManifest, InstalledExtensions } from "@roo-code/types" + +import { GlobalFileNames } from "../../shared/globalFileNames" +import { ensureSettingsDirectoryExists } from "../../utils/globalContext" +import { safeWriteJson } from "../../utils/safeWriteJson" +import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config" +import { fileExistsAtPath } from "../../utils/fs" +import type { PluginSourceRef } from "./GitHubSource" +import { fetchFileFromGitHub } from "./GitHubSource" + +export interface PluginInstallContext { + sourceRef: PluginSourceRef + manifest: PluginManifest + target: "project" | "global" + cwd: string + extensionContext: import("vscode").ExtensionContext +} + +/** + * Install all extension points defined in a plugin manifest. + * Returns the record of what was installed for tracking purposes. + */ +export async function installPluginExtensions(ctx: PluginInstallContext): Promise { + const installed: InstalledExtensions = { + commands: [], + modes: [], + mcpServers: [], + skills: [], + } + + // Install commands + if (ctx.manifest.commands && ctx.manifest.commands.length > 0) { + const commandNames = await installCommands(ctx) + installed.commands = commandNames + } + + // Install modes + if (ctx.manifest.modes && ctx.manifest.modes.length > 0) { + const modeNames = await installModes(ctx) + installed.modes = modeNames + } + + // Install MCP servers + if (ctx.manifest.mcpServers && Object.keys(ctx.manifest.mcpServers).length > 0) { + const serverNames = await installMcpServers(ctx) + installed.mcpServers = serverNames + } + + // Install skills + if (ctx.manifest.skills && ctx.manifest.skills.length > 0) { + const skillNames = await installSkills(ctx) + installed.skills = skillNames + } + + return installed +} + +/** + * Remove all extension points installed by a plugin. + */ +export async function removePluginExtensions( + installed: InstalledExtensions, + target: "project" | "global", + cwd: string, + extensionContext: import("vscode").ExtensionContext, +): Promise { + // Remove commands + for (const commandName of installed.commands) { + await removeCommand(commandName, target, cwd) + } + + // Remove modes + for (const modeName of installed.modes) { + await removeMode(modeName, target, cwd, extensionContext) + } + + // Remove MCP servers + for (const serverName of installed.mcpServers) { + await removeMcpServer(serverName, target, cwd, extensionContext) + } + + // Remove skills + for (const skillName of installed.skills) { + await removeSkill(skillName, target, cwd) + } +} + +// --- Commands --- + +async function getCommandsDir(target: "project" | "global", cwd: string): Promise { + if (target === "project") { + const projectDir = getProjectRooDirectoryForCwd(cwd) + return path.join(projectDir, "commands") + } + const globalDir = getGlobalRooDirectory() + return path.join(globalDir, "commands") +} + +async function installCommands(ctx: PluginInstallContext): Promise { + const commandsDir = await getCommandsDir(ctx.target, ctx.cwd) + await fs.mkdir(commandsDir, { recursive: true }) + + const installed: string[] = [] + + for (const cmd of ctx.manifest.commands!) { + const content = await fetchFileFromGitHub(ctx.sourceRef, cmd.file) + const targetPath = path.join(commandsDir, `${cmd.name}.md`) + await fs.writeFile(targetPath, content, "utf-8") + installed.push(cmd.name) + } + + return installed +} + +async function removeCommand(name: string, target: "project" | "global", cwd: string): Promise { + const commandsDir = await getCommandsDir(target, cwd) + const filePath = path.join(commandsDir, `${name}.md`) + try { + await fs.unlink(filePath) + } catch { + // File may not exist - that's okay + } +} + +// --- Modes --- + +async function getModeFilePath( + target: "project" | "global", + cwd: string, + extensionContext: import("vscode").ExtensionContext, +): Promise { + if (target === "project") { + return path.join(cwd, ".roomodes") + } + const settingsDir = await ensureSettingsDirectoryExists(extensionContext) + return path.join(settingsDir, GlobalFileNames.customModes) +} + +async function installModes(ctx: PluginInstallContext): Promise { + const installed: string[] = [] + + for (const modeEntry of ctx.manifest.modes!) { + const content = await fetchFileFromGitHub(ctx.sourceRef, modeEntry.file) + + // Parse the mode YAML to get the slug + let modeData: Record + try { + modeData = yaml.parse(content) + } catch { + throw new Error(`Invalid YAML in mode file: ${modeEntry.file}`) + } + + const slug = modeData.slug as string + if (!slug) { + throw new Error(`Mode file ${modeEntry.file} is missing a "slug" field`) + } + + // Merge into the target modes file + const modesFilePath = await getModeFilePath(ctx.target, ctx.cwd, ctx.extensionContext) + + let existingData: { customModes: Record[] } = { customModes: [] } + try { + const existingContent = await fs.readFile(modesFilePath, "utf-8") + existingData = yaml.parse(existingContent) || { customModes: [] } + if (!existingData.customModes) { + existingData.customModes = [] + } + } catch { + // File doesn't exist yet - use defaults + } + + // Remove existing mode with same slug + existingData.customModes = existingData.customModes.filter((m) => m.slug !== slug) + existingData.customModes.push(modeData) + + await fs.writeFile(modesFilePath, yaml.stringify(existingData, { lineWidth: 0 }), "utf-8") + installed.push(slug) + } + + return installed +} + +async function removeMode( + slug: string, + target: "project" | "global", + cwd: string, + extensionContext: import("vscode").ExtensionContext, +): Promise { + const modesFilePath = await getModeFilePath(target, cwd, extensionContext) + try { + const content = await fs.readFile(modesFilePath, "utf-8") + const data = yaml.parse(content) || { customModes: [] } + if (data.customModes) { + data.customModes = data.customModes.filter((m: Record) => m.slug !== slug) + await fs.writeFile(modesFilePath, yaml.stringify(data, { lineWidth: 0 }), "utf-8") + } + } catch { + // File doesn't exist - nothing to remove + } +} + +// --- MCP Servers --- + +async function getMcpSettingsPath( + target: "project" | "global", + cwd: string, + extensionContext: import("vscode").ExtensionContext, +): Promise { + if (target === "project") { + const rooDir = path.join(cwd, ".roo") + await fs.mkdir(rooDir, { recursive: true }) + return path.join(rooDir, "mcp.json") + } + const settingsDir = await ensureSettingsDirectoryExists(extensionContext) + return path.join(settingsDir, GlobalFileNames.mcpSettings) +} + +async function installMcpServers(ctx: PluginInstallContext): Promise { + const mcpPath = await getMcpSettingsPath(ctx.target, ctx.cwd, ctx.extensionContext) + + let existingData: { mcpServers: Record } = { mcpServers: {} } + try { + const content = await fs.readFile(mcpPath, "utf-8") + existingData = JSON.parse(content) + if (!existingData.mcpServers) { + existingData.mcpServers = {} + } + } catch { + // File doesn't exist yet + } + + const installed: string[] = [] + for (const [serverName, serverConfig] of Object.entries(ctx.manifest.mcpServers!)) { + existingData.mcpServers[serverName] = serverConfig + installed.push(serverName) + } + + await safeWriteJson(mcpPath, existingData) + return installed +} + +async function removeMcpServer( + serverName: string, + target: "project" | "global", + cwd: string, + extensionContext: import("vscode").ExtensionContext, +): Promise { + const mcpPath = await getMcpSettingsPath(target, cwd, extensionContext) + try { + const content = await fs.readFile(mcpPath, "utf-8") + const data = JSON.parse(content) + if (data.mcpServers && data.mcpServers[serverName]) { + delete data.mcpServers[serverName] + await safeWriteJson(mcpPath, data) + } + } catch { + // File doesn't exist - nothing to remove + } +} + +// --- Skills --- + +async function getSkillsDir(target: "project" | "global", cwd: string): Promise { + if (target === "project") { + const projectDir = getProjectRooDirectoryForCwd(cwd) + return path.join(projectDir, "skills") + } + const globalDir = getGlobalRooDirectory() + return path.join(globalDir, "skills") +} + +async function installSkills(ctx: PluginInstallContext): Promise { + const skillsDir = await getSkillsDir(ctx.target, ctx.cwd) + const installed: string[] = [] + + for (const skill of ctx.manifest.skills!) { + const skillDir = path.join(skillsDir, skill.name) + await fs.mkdir(skillDir, { recursive: true }) + + // Fetch the SKILL.md file from the skill directory in the plugin repo + const skillMdPath = path.posix.join(skill.directory, "SKILL.md") + try { + const content = await fetchFileFromGitHub(ctx.sourceRef, skillMdPath) + await fs.writeFile(path.join(skillDir, "SKILL.md"), content, "utf-8") + installed.push(skill.name) + } catch (error) { + throw new Error( + `Failed to install skill "${skill.name}": ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + return installed +} + +async function removeSkill(name: string, target: "project" | "global", cwd: string): Promise { + const skillsDir = await getSkillsDir(target, cwd) + const skillDir = path.join(skillsDir, name) + try { + if (await fileExistsAtPath(skillDir)) { + await fs.rm(skillDir, { recursive: true, force: true }) + } + } catch { + // Directory may not exist - that's okay + } +} diff --git a/src/services/plugin/PluginManager.ts b/src/services/plugin/PluginManager.ts new file mode 100644 index 00000000000..ec130abc382 --- /dev/null +++ b/src/services/plugin/PluginManager.ts @@ -0,0 +1,165 @@ +import * as fs from "fs/promises" +import * as path from "path" +import * as vscode from "vscode" + +import type { InstalledPlugin, PluginsFile, PluginManifest } from "@roo-code/types" +import { pluginsFileSchema } from "@roo-code/types" + +import { GlobalFileNames } from "../../shared/globalFileNames" +import { ensureSettingsDirectoryExists } from "../../utils/globalContext" +import { safeWriteJson } from "../../utils/safeWriteJson" +import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config" +import { parsePluginSource, fetchPluginManifest, type PluginSourceRef } from "./GitHubSource" +import { installPluginExtensions, removePluginExtensions, type PluginInstallContext } from "./PluginInstaller" + +export class PluginManager { + constructor( + private readonly extensionContext: vscode.ExtensionContext, + private readonly cwd: string, + ) {} + + /** + * Install a plugin from a GitHub repository source. + * + * @param source - GitHub repo in "owner/repo" or "owner/repo@ref" format + * @param target - Install to "project" (.roo/) or "global" (~/.roo/) + * @returns The installed plugin record + */ + async install(source: string, target: "project" | "global" = "project"): Promise { + const sourceRef = parsePluginSource(source) + + // Fetch and validate manifest + const manifest = await fetchPluginManifest(sourceRef) + + // Check if already installed + const existing = await this.getInstalledPlugin(manifest.name, target) + if (existing) { + throw new Error( + `Plugin "${manifest.name}" is already installed (${target}). Remove it first with /plugin remove ${manifest.name}`, + ) + } + + // Install all extension points + const ctx: PluginInstallContext = { + sourceRef, + manifest, + target, + cwd: this.cwd, + extensionContext: this.extensionContext, + } + + const installedExtensions = await installPluginExtensions(ctx) + + // Create install record + const record: InstalledPlugin = { + name: manifest.name, + version: manifest.version, + source: `${sourceRef.owner}/${sourceRef.repo}`, + ref: sourceRef.ref, + installedAt: new Date().toISOString(), + target, + installedExtensions, + } + + // Save to tracking file + await this.addPluginRecord(record, target) + + return record + } + + /** + * Remove an installed plugin and clean up all its extension points. + */ + async remove(pluginName: string, target?: "project" | "global"): Promise { + // Find the plugin - check both project and global if target not specified + let record: InstalledPlugin | undefined + let recordTarget: "project" | "global" + + if (target) { + record = await this.getInstalledPlugin(pluginName, target) + recordTarget = target + } else { + // Check project first, then global + record = await this.getInstalledPlugin(pluginName, "project") + recordTarget = "project" + if (!record) { + record = await this.getInstalledPlugin(pluginName, "global") + recordTarget = "global" + } + } + + if (!record) { + throw new Error(`Plugin "${pluginName}" is not installed.`) + } + + // Remove all installed extension points + await removePluginExtensions(record.installedExtensions, recordTarget!, this.cwd, this.extensionContext) + + // Remove from tracking file + await this.removePluginRecord(pluginName, recordTarget!) + } + + /** + * List all installed plugins (both project and global). + */ + async list(): Promise { + const projectPlugins = await this.readPluginsFile("project") + const globalPlugins = await this.readPluginsFile("global") + return [...projectPlugins.installedPlugins, ...globalPlugins.installedPlugins] + } + + /** + * Get a specific installed plugin by name and target. + */ + async getInstalledPlugin(name: string, target: "project" | "global"): Promise { + const pluginsFile = await this.readPluginsFile(target) + return pluginsFile.installedPlugins.find((p) => p.name === name) + } + + // --- Private helpers --- + + private async getPluginsFilePath(target: "project" | "global"): Promise { + if (target === "project") { + const projectDir = getProjectRooDirectoryForCwd(this.cwd) + await fs.mkdir(projectDir, { recursive: true }) + return path.join(projectDir, GlobalFileNames.plugins) + } + const settingsDir = await ensureSettingsDirectoryExists(this.extensionContext) + return path.join(settingsDir, GlobalFileNames.plugins) + } + + private async readPluginsFile(target: "project" | "global"): Promise { + const filePath = await this.getPluginsFilePath(target) + try { + const content = await fs.readFile(filePath, "utf-8") + const parsed = JSON.parse(content) + const result = pluginsFileSchema.safeParse(parsed) + if (result.success) { + return result.data + } + } catch { + // File doesn't exist or is invalid + } + return { installedPlugins: [] } + } + + private async addPluginRecord(record: InstalledPlugin, target: "project" | "global"): Promise { + const filePath = await this.getPluginsFilePath(target) + const pluginsFile = await this.readPluginsFile(target) + + // Remove existing record with same name (shouldn't happen but be safe) + pluginsFile.installedPlugins = pluginsFile.installedPlugins.filter((p) => p.name !== record.name) + pluginsFile.installedPlugins.push(record) + + await safeWriteJson(filePath, pluginsFile) + } + + private async removePluginRecord(pluginName: string, target: "project" | "global"): Promise { + const filePath = await this.getPluginsFilePath(target) + const pluginsFile = await this.readPluginsFile(target) + + pluginsFile.installedPlugins = pluginsFile.installedPlugins.filter((p) => p.name !== pluginName) + + await safeWriteJson(filePath, pluginsFile) + } +} diff --git a/src/services/plugin/__tests__/GitHubSource.spec.ts b/src/services/plugin/__tests__/GitHubSource.spec.ts new file mode 100644 index 00000000000..04e85ea9b4b --- /dev/null +++ b/src/services/plugin/__tests__/GitHubSource.spec.ts @@ -0,0 +1,195 @@ +import { parsePluginSource, buildRawUrl, fetchFileFromGitHub, fetchPluginManifest } from "../GitHubSource" + +// Mock global fetch +const mockFetch = vi.fn() +global.fetch = mockFetch + +describe("GitHubSource", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("parsePluginSource", () => { + it("should parse owner/repo format", () => { + const result = parsePluginSource("myowner/myrepo") + expect(result).toEqual({ + owner: "myowner", + repo: "myrepo", + ref: "main", + }) + }) + + it("should parse owner/repo@ref format", () => { + const result = parsePluginSource("myowner/myrepo@v1.0.0") + expect(result).toEqual({ + owner: "myowner", + repo: "myrepo", + ref: "v1.0.0", + }) + }) + + it("should parse owner/repo@branch format", () => { + const result = parsePluginSource("org/repo@develop") + expect(result).toEqual({ + owner: "org", + repo: "repo", + ref: "develop", + }) + }) + + it("should default ref to main when @ is present but ref is empty", () => { + const result = parsePluginSource("owner/repo@") + expect(result).toEqual({ + owner: "owner", + repo: "repo", + ref: "main", + }) + }) + + it("should throw for invalid format - no slash", () => { + expect(() => parsePluginSource("invalidformat")).toThrow("Invalid plugin source format") + }) + + it("should throw for invalid format - empty owner", () => { + expect(() => parsePluginSource("/repo")).toThrow("Invalid plugin source format") + }) + + it("should throw for invalid format - empty repo", () => { + expect(() => parsePluginSource("owner/")).toThrow("Invalid plugin source format") + }) + + it("should throw for invalid format - too many parts", () => { + expect(() => parsePluginSource("a/b/c")).toThrow("Invalid plugin source format") + }) + }) + + describe("buildRawUrl", () => { + it("should build correct raw URL", () => { + const url = buildRawUrl({ owner: "owner", repo: "repo", ref: "main" }, "plugin.json") + expect(url).toBe("https://raw.githubusercontent.com/owner/repo/main/plugin.json") + }) + + it("should handle nested paths", () => { + const url = buildRawUrl({ owner: "o", repo: "r", ref: "v1" }, "commands/review.md") + expect(url).toBe("https://raw.githubusercontent.com/o/r/v1/commands/review.md") + }) + }) + + describe("fetchFileFromGitHub", () => { + it("should fetch file content", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("# Hello"), + }) + + const content = await fetchFileFromGitHub({ owner: "o", repo: "r", ref: "main" }, "README.md") + expect(content).toBe("# Hello") + expect(mockFetch).toHaveBeenCalledWith("https://raw.githubusercontent.com/o/r/main/README.md") + }) + + it("should throw on 404", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + }) + + await expect(fetchFileFromGitHub({ owner: "o", repo: "r", ref: "main" }, "missing.txt")).rejects.toThrow( + "File not found: missing.txt", + ) + }) + + it("should throw on other HTTP errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }) + + await expect(fetchFileFromGitHub({ owner: "o", repo: "r", ref: "main" }, "file.txt")).rejects.toThrow( + "Failed to fetch file.txt: HTTP 500 Internal Server Error", + ) + }) + }) + + describe("fetchPluginManifest", () => { + it("should fetch and validate a valid manifest", async () => { + const manifest = { + name: "test-plugin", + version: "1.0.0", + description: "A test plugin", + commands: [{ name: "review", file: "commands/review.md" }], + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify(manifest)), + }) + + const result = await fetchPluginManifest({ owner: "o", repo: "r", ref: "main" }) + expect(result.name).toBe("test-plugin") + expect(result.version).toBe("1.0.0") + expect(result.commands).toHaveLength(1) + expect(result.commands![0].name).toBe("review") + }) + + it("should throw on invalid JSON", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("not valid json {{{"), + }) + + await expect(fetchPluginManifest({ owner: "o", repo: "r", ref: "main" })).rejects.toThrow("Invalid JSON") + }) + + it("should throw on invalid manifest schema", async () => { + const invalidManifest = { + // Missing required "name" field + version: "1.0.0", + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify(invalidManifest)), + }) + + await expect(fetchPluginManifest({ owner: "o", repo: "r", ref: "main" })).rejects.toThrow( + "Invalid plugin manifest", + ) + }) + + it("should throw on invalid plugin name format", async () => { + const manifest = { + name: "invalid name with spaces!", + version: "1.0.0", + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify(manifest)), + }) + + await expect(fetchPluginManifest({ owner: "o", repo: "r", ref: "main" })).rejects.toThrow( + "Invalid plugin manifest", + ) + }) + + it("should apply defaults for optional fields", async () => { + const manifest = { + name: "minimal-plugin", + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify(manifest)), + }) + + const result = await fetchPluginManifest({ owner: "o", repo: "r", ref: "main" }) + expect(result.name).toBe("minimal-plugin") + expect(result.version).toBe("1.0.0") + expect(result.commands).toEqual([]) + expect(result.modes).toEqual([]) + expect(result.skills).toEqual([]) + }) + }) +}) diff --git a/src/services/plugin/__tests__/PluginManager.spec.ts b/src/services/plugin/__tests__/PluginManager.spec.ts new file mode 100644 index 00000000000..46ccf83a285 --- /dev/null +++ b/src/services/plugin/__tests__/PluginManager.spec.ts @@ -0,0 +1,233 @@ +import * as fs from "fs/promises" +import * as path from "path" + +import { PluginManager } from "../PluginManager" +import * as GitHubSource from "../GitHubSource" +import * as PluginInstaller from "../PluginInstaller" + +// Mock dependencies +vi.mock("fs/promises") +vi.mock("../GitHubSource") +vi.mock("../PluginInstaller") +vi.mock("../../../utils/globalContext", () => ({ + ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/settings"), +})) +vi.mock("../../roo-config", () => ({ + getGlobalRooDirectory: vi.fn().mockReturnValue("/mock/home/.roo"), + getProjectRooDirectoryForCwd: vi.fn().mockReturnValue("/mock/project/.roo"), +})) +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn().mockResolvedValue(undefined), +})) + +describe("PluginManager", () => { + let manager: PluginManager + const mockExtensionContext = {} as any + + beforeEach(() => { + vi.clearAllMocks() + manager = new PluginManager(mockExtensionContext, "/mock/project") + }) + + describe("install", () => { + it("should install a plugin from GitHub", async () => { + const mockManifest = { + name: "test-plugin", + version: "1.0.0", + description: "Test", + commands: [{ name: "review", file: "commands/review.md" }], + modes: [], + skills: [], + } + + vi.mocked(GitHubSource.parsePluginSource).mockReturnValue({ + owner: "owner", + repo: "repo", + ref: "main", + }) + + vi.mocked(GitHubSource.fetchPluginManifest).mockResolvedValue(mockManifest as any) + + // Mock empty plugins file (not installed yet) + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")) + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any) + + vi.mocked(PluginInstaller.installPluginExtensions).mockResolvedValue({ + commands: ["review"], + modes: [], + mcpServers: [], + skills: [], + }) + + const result = await manager.install("owner/repo") + + expect(result.name).toBe("test-plugin") + expect(result.version).toBe("1.0.0") + expect(result.source).toBe("owner/repo") + expect(result.ref).toBe("main") + expect(result.target).toBe("project") + expect(result.installedExtensions.commands).toEqual(["review"]) + expect(GitHubSource.fetchPluginManifest).toHaveBeenCalled() + expect(PluginInstaller.installPluginExtensions).toHaveBeenCalled() + }) + + it("should throw if plugin is already installed", async () => { + const existingPlugins = { + installedPlugins: [ + { + name: "test-plugin", + version: "1.0.0", + source: "owner/repo", + ref: "main", + installedAt: "2024-01-01T00:00:00Z", + target: "project", + installedExtensions: { commands: [], modes: [], mcpServers: [], skills: [] }, + }, + ], + } + + vi.mocked(GitHubSource.parsePluginSource).mockReturnValue({ + owner: "owner", + repo: "repo", + ref: "main", + }) + + vi.mocked(GitHubSource.fetchPluginManifest).mockResolvedValue({ + name: "test-plugin", + version: "1.0.0", + commands: [], + modes: [], + skills: [], + } as any) + + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any) + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingPlugins) as any) + + await expect(manager.install("owner/repo")).rejects.toThrow("already installed") + }) + + it("should install to global scope when specified", async () => { + vi.mocked(GitHubSource.parsePluginSource).mockReturnValue({ + owner: "owner", + repo: "repo", + ref: "main", + }) + + vi.mocked(GitHubSource.fetchPluginManifest).mockResolvedValue({ + name: "global-plugin", + version: "1.0.0", + commands: [], + modes: [], + skills: [], + } as any) + + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")) + + vi.mocked(PluginInstaller.installPluginExtensions).mockResolvedValue({ + commands: [], + modes: [], + mcpServers: [], + skills: [], + }) + + const result = await manager.install("owner/repo", "global") + + expect(result.target).toBe("global") + }) + }) + + describe("remove", () => { + it("should remove an installed plugin", async () => { + const existingPlugins = { + installedPlugins: [ + { + name: "test-plugin", + version: "1.0.0", + source: "owner/repo", + ref: "main", + installedAt: "2024-01-01T00:00:00Z", + target: "project", + installedExtensions: { + commands: ["review"], + modes: [], + mcpServers: [], + skills: [], + }, + }, + ], + } + + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any) + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingPlugins) as any) + vi.mocked(PluginInstaller.removePluginExtensions).mockResolvedValue(undefined) + + await manager.remove("test-plugin") + + expect(PluginInstaller.removePluginExtensions).toHaveBeenCalledWith( + existingPlugins.installedPlugins[0].installedExtensions, + "project", + "/mock/project", + mockExtensionContext, + ) + }) + + it("should throw if plugin is not installed", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any) + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")) + + await expect(manager.remove("nonexistent")).rejects.toThrow("not installed") + }) + }) + + describe("list", () => { + it("should list plugins from both project and global", async () => { + const projectPlugins = { + installedPlugins: [ + { + name: "project-plugin", + version: "1.0.0", + source: "o/r", + ref: "main", + installedAt: "2024-01-01T00:00:00Z", + target: "project", + installedExtensions: { commands: [], modes: [], mcpServers: [], skills: [] }, + }, + ], + } + + const globalPlugins = { + installedPlugins: [ + { + name: "global-plugin", + version: "2.0.0", + source: "g/r", + ref: "main", + installedAt: "2024-01-01T00:00:00Z", + target: "global", + installedExtensions: { commands: [], modes: [], mcpServers: [], skills: [] }, + }, + ], + } + + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any) + // First call is for project, second for global + vi.mocked(fs.readFile) + .mockResolvedValueOnce(JSON.stringify(projectPlugins) as any) + .mockResolvedValueOnce(JSON.stringify(globalPlugins) as any) + + const result = await manager.list() + + expect(result).toHaveLength(2) + expect(result[0].name).toBe("project-plugin") + expect(result[1].name).toBe("global-plugin") + }) + + it("should return empty array when no plugins installed", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any) + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")) + + const result = await manager.list() + expect(result).toEqual([]) + }) + }) +}) diff --git a/src/shared/globalFileNames.ts b/src/shared/globalFileNames.ts index 0b54ff6809c..4fc15e68e14 100644 --- a/src/shared/globalFileNames.ts +++ b/src/shared/globalFileNames.ts @@ -6,4 +6,5 @@ export const GlobalFileNames = { taskMetadata: "task_metadata.json", historyItem: "history_item.json", historyIndex: "_index.json", + plugins: "plugins.json", }